diff --git a/README.md b/README.md index fff2f02..ffc2c0c 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,14 @@ # ⚡ cfray -### Cloudflare Config Scanner & Clean IP Finder +### Cloudflare Proxy Toolkit -**Test VLESS/VMess proxy configs for latency & speed + Scan all ~1.5M Cloudflare IPs to find clean, reachable edges** +**Scan configs + Find clean IPs + Deploy Xray servers + Pipeline test with DPI bypass + Worker proxy** [![Python 3.8+](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://python.org) [![Zero Dependencies](https://img.shields.io/badge/Dependencies-Zero-green.svg)](#) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Version 1.1](https://img.shields.io/badge/Version-1.1-orange.svg)](#) [English](#-english) • [فارسی](#-فارسی) @@ -20,7 +21,14 @@ ### 📖 What is cfray? -cfray is a single-file Python tool that finds the **fastest Cloudflare edge IPs** for your VLESS/VMess proxy configs. It tests latency (ping) and download speed across hundreds of IPs, ranks them by score, and exports the best configs — ready to use. It also includes a **Clean IP Finder** that scans all ~1.5M Cloudflare IPv4 addresses (from 14 published subnets) to discover reachable edge IPs. Mega mode tests each IP on 2 ports for ~3M total probes. +cfray is a single-file Python proxy toolkit for VLESS/VMess configs behind Cloudflare. What started as an IP scanner is now a full suite: + +- **Config Scanner** — test hundreds of IPs for latency + download speed, rank by score, export the best configs +- **Clean IP Finder** — scan all ~1.5M Cloudflare IPv4 addresses to find reachable edge IPs (Mega mode: ~3M probes on 2 ports) +- **Xray Pipeline Test** — 3-stage pipeline that scans IPs, swaps them into your config, and expands with fragment presets + SNI variations to bypass DPI/firewall blocks +- **Deploy Xray Server** — deploy a full xray-core server on any Linux VPS with systemd, TLS certs, REALITY keys, and multi-config support +- **Worker Proxy** — generate a Cloudflare Worker script for a fresh `workers.dev` SNI on any VLESS WebSocket config +- **Connection Manager** — manage inbounds, users, URIs, and uninstall on deployed servers **Zero dependencies.** Just Python 3.8+ and one file. @@ -180,12 +188,12 @@ After the scan, you get an interactive dashboard: | `S` | 🔄 Cycle sort: score → latency → speed | | `C` | 📋 View all VLESS/VMess URIs for an IP | | `D` | 🌐 View domains for an IP | -| `E` | 💾 Export results (CSV + top N configs) | -| `A` | 📦 Export ALL configs sorted best → worst | +| `E` | 💾 Export results (CSV + top N configs) | +| `A` | 📦 Export ALL configs sorted best → worst | | `J/K` | ⬆️⬇️ Scroll up/down | -| `N/P` | 📄 Page up/down | -| `B` | ◀️ Back to main menu (new scan) | -| `H` | ❓ Help | +| `N/P` | 📄 Page up/down | +| `B` | ◀️ Back to main menu (new scan) | +| `H` | ❓ Help | | `Q` | 🚪 Quit | --- @@ -271,11 +279,229 @@ cfray is smart about Cloudflare's speed test limits: --- +### 🆕 What's New in v1.1 + +v1.1 adds **server deployment**, **pipeline testing**, **worker proxy**, and a **connection manager** — turning cfray from a scanner into a full proxy toolkit. + +--- + +### ⚡ Xray Pipeline Test `[X]` + +A smart 3-stage pipeline that takes a single VLESS/VMess config and finds the best way to connect through it — including DPI bypass via TLS fragmentation. + +**How it works:** + +``` +Stage 1: 🔍 IP Scan + Scans Cloudflare IP ranges to find clean, reachable IPs. + Tests thousands of IPs in parallel via TLS handshake. + +Stage 2: 🧪 Base Test + Swaps each clean IP into your config and tests if it actually + passes traffic. Uses a direct VLESS tunnel to verify real + connectivity — not just a handshake. Filters down to IPs + that work with your specific config. + +Stage 3: 🔧 Fragment Expansion + Takes the working IPs and expands them with DPI bypass fragment + presets + SNI variations: + - light: 100-200 byte fragments, 10-20ms interval + - medium: 50-200 byte fragments, 10-40ms interval + - heavy: 10-300 byte fragments, 5-50ms interval + Finds the best combination of IP + fragment + SNI. +``` + +**Use case:** You have a config that doesn't connect (blocked by DPI/firewall). Instead of manually trying different IPs and fragment settings, the pipeline automatically finds working combinations. + +**xray-core** is required — cfray downloads it automatically on first use or you can install manually: + +```bash +# Auto-install xray-core +python3 scanner.py --xray-install + +# Interactive pipeline +python3 scanner.py # Press X + +# CLI — test a specific config +python3 scanner.py --xray 'vless://uuid@domain:443?type=ws&security=tls#myconfig' + +# CLI — only heavy fragments, keep top 5 +python3 scanner.py --xray 'vless://...' --xray-frag heavy --xray-keep 5 +``` + +--- + +### 🚀 Deploy Xray Server `[D]` + +Deploy a fully configured Xray proxy server on any Linux VPS in under 2 minutes. The wizard walks you through every step — protocol, transport, security, port — and generates ready-to-use client URIs. + +**What it sets up:** +- Downloads and installs **xray-core** binary +- Generates server config with your chosen protocol/transport/security +- Creates **systemd service** for auto-start on boot +- Obtains **TLS certificates** via certbot (for TLS security) +- Generates **x25519 keypair** (for REALITY security) +- Outputs client VLESS/VMess URIs you can import directly into your app + +**Supported options:** + +| Category | Options | +|------------|----------------------------------------------| +| Protocol | VLESS, VMess | +| Transport | TCP, WebSocket, gRPC, HTTP/2, XHTTP | +| Security | REALITY, TLS, None | +| Ports | Any port (default 443) | + +> **Note:** REALITY mode only supports TCP, gRPC, and HTTP/2 transports. WebSocket and XHTTP are available with TLS or None security. + +**REALITY** is the recommended security mode — it doesn't need a domain or TLS certificates. It uses x25519 key exchange with a "camouflage" SNI (like `yahoo.com` or `google.com`) to make traffic look like normal HTTPS. + +**XHTTP** (also called SplitHTTP) is a CDN-compatible transport that works well behind Cloudflare and other CDN providers. It splits HTTP requests in a way that bypasses many DPI systems. + +**Multi-config deploy:** You can deploy multiple protocol configurations in a single session. For example, deploy TCP+REALITY on port 443 *and* WS+TLS on port 444 on the same server. Each config gets its own UUID and port. REALITY keys and TLS certificates are generated once and reused across configs. + +```bash +# Interactive wizard (recommended) +python3 scanner.py # Press D + +# CLI — deploy TCP+REALITY +python3 scanner.py --deploy --deploy-transport tcp --deploy-security reality + +# CLI — deploy WS+TLS with custom domain +python3 scanner.py --deploy --deploy-transport ws --deploy-security tls --deploy-sni yourdomain.com + +# CLI — custom port and protocol +python3 scanner.py --deploy --deploy-protocol vmess --deploy-transport grpc --deploy-port 8443 +``` + +**After deploy,** you get an interactive menu: +- **[V] View URIs** — display all generated client configs again (they don't disappear) +- **[M] Connection Manager** — jump straight to managing the server +- **[Q] Back** — return to main menu + +--- + +### ☁️ Worker Proxy `[O]` + +Get a fresh **Cloudflare Workers** SNI for any VLESS config. cfray generates a Worker script that proxies WebSocket traffic to your backend server — you deploy it to your Cloudflare account and get a clean, unblocked `workers.dev` SNI. + +**How it works:** +1. You provide a working VLESS config (WebSocket transport required) +2. cfray generates a JavaScript Worker script that relays WebSocket connections to your backend +3. cfray shows the script + step-by-step instructions to deploy it on `dash.cloudflare.com` +4. You paste the resulting Worker URL back into cfray +5. cfray outputs a new config with the fresh `*.workers.dev` domain as SNI + +**Use case:** Your config works but the SNI/domain is blocked in your region. Instead of finding a new domain, you create a Workers proxy that gives you a clean `workers.dev` SNI. Since Cloudflare Workers domains are widely used for legitimate purposes, they're rarely blocked. + +**Requirements:** +- A Cloudflare account (free tier works) +- Your original config must use **WebSocket** transport (Workers only proxy WS traffic) + +```bash +# Interactive +python3 scanner.py # Press O + +# The wizard will ask for: +# 1. Your VLESS config URI +# 2. Generates script + shows manual deploy instructions +# 3. Your Worker URL (after you deploy it on dash.cloudflare.com) +``` + +--- + +### 🔧 Connection Manager `[C]` + +Manage an existing Xray server's configuration directly — add/remove inbounds, manage users, view client URIs, and uninstall. Works with any xray-core server deployed by cfray. + +**What you can do:** + +| Key | Action | +|-----|--------| +| `A` | **Add inbound** — create a new protocol/transport/port | +| `V` | **View** — view inbound JSON details | +| `U` | **Add user** — add a new UUID to an existing inbound | +| `X` | **Remove inbound** — delete an existing inbound | +| `S` | **Show URIs** — display all client URIs for every user | +| `R` | **Restart xray** — restart the xray service | +| `L` | **Logs** — view xray service logs | +| `D` | **Uninstall** — completely remove xray, configs, systemd | +| `B` | **Back** — return to main menu | + +**Show URIs** generates VLESS/VMess client URIs from the server's config file for every inbound and every user. This is useful when you've deployed multiple configs and need to share the URIs with users, or when you've lost the original URIs from deploy time. + +**Uninstall** completely removes everything cfray installed: stops the xray service, removes the binary, deletes config files, removes the systemd service, and cleans up the `~/.cfray/` directory. Requires typing "uninstall" to confirm (safety check). + +```bash +# Interactive +python3 scanner.py # Press C +``` + +--- + +### 🔧 Updated CLI Options + +New flags added in v1.1: + +| Option | Description | Default | +|---------------------|----------------------------------------------|---------------| +| `--xray URI` | Test a VLESS/VMess URI through xray pipeline | — | +| `--xray-frag` | Fragment preset: `none`/`light`/`medium`/`heavy`/`all` | `all` | +| `--xray-bin PATH` | Path to xray binary (auto-detect if not set) | auto | +| `--xray-install` | Download and install xray-core to `~/.cfray/bin/` | off | +| `--xray-keep N` | Export top N pipeline results | 10 | +| `--deploy` | Deploy Xray server on this Linux VPS | — | +| `--deploy-port` | Server listen port | 443 | +| `--deploy-protocol` | `vless` / `vmess` | `vless` | +| `--deploy-transport`| `tcp` / `ws` / `grpc` / `h2` | `tcp` | +| `--deploy-security` | `reality` / `tls` / `none` | `reality` | +| `--deploy-sni` | SNI domain for TLS/REALITY | — | +| `--deploy-cert` | Path to TLS certificate | — | +| `--deploy-key` | Path to TLS private key | — | +| `--deploy-ip` | Override auto-detected server IP | auto | +| `--uninstall` | Remove everything cfray installed | off | + +--- + +### 💡 More Examples (v1.1) + +```bash +# Install xray-core (needed for Pipeline Test) +python3 scanner.py --xray-install + +# Pipeline test — find working IP + fragment combo for a blocked config +python3 scanner.py --xray 'vless://uuid@blocked-domain:443?type=ws&security=tls#config' + +# Pipeline test — only try heavy fragments, export top 3 +python3 scanner.py --xray 'vless://...' --xray-frag heavy --xray-keep 3 + +# Deploy — quick TCP+REALITY server (recommended for beginners) +python3 scanner.py --deploy + +# Deploy — WS+TLS for CDN routing +python3 scanner.py --deploy --deploy-transport ws --deploy-security tls --deploy-sni example.com + +# Deploy — VMess over gRPC +python3 scanner.py --deploy --deploy-protocol vmess --deploy-transport grpc + +# Uninstall everything cfray deployed +python3 scanner.py --uninstall +``` + +--- + ## 🇮🇷 فارسی ### 📖 cfray چیه؟ -cfray یه ابزار پایتونه که **سریع‌ترین آی‌پی‌های کلادفلر** رو برای کانفیگ‌های VLESS/VMess پیدا می‌کنه. پینگ و سرعت دانلود رو تست می‌کنه، بهترین‌ها رو امتیاز میده و خروجی آماده استفاده میده. همچنین شامل **جستجوگر آی‌پی تمیز** هست که تمام ~۱.۵ میلیون آی‌پی IPv4 کلادفلر (از ۱۴ زیرشبکه) رو اسکن می‌کنه. حالت Mega هر آی‌پی رو روی ۲ پورت تست می‌کنه (~۳ میلیون پروب). +cfray یه ابزار کامل پایتونی برای کانفیگ‌های VLESS/VMess پشت کلادفلره. یه فایل تکی که همه چیز رو داره: + +- **اسکنر کانفیگ** — تست صدها آی‌پی برای پینگ + سرعت دانلود، رتبه‌بندی و خروجی بهترین کانفیگ‌ها +- **جستجوگر آی‌پی تمیز** — اسکن تمام ~۱.۵ میلیون آی‌پی IPv4 کلادفلر (حالت Mega: ~۳ میلیون پروب روی ۲ پورت) +- **تست پایپلاین Xray** — پایپلاین ۳ مرحله‌ای: اسکن آی‌پی، جایگزینی توی کانفیگ، گسترش با فرگمنت و SNI برای دور زدن DPI/فایروال +- **دیپلوی سرور Xray** — نصب سرور xray-core روی هر VPS لینوکسی با systemd، گواهی TLS، کلید REALITY و پشتیبانی چند کانفیگ +- **پروکسی ورکر** — تولید اسکریپت Worker کلادفلر برای SNI تازه `workers.dev` روی هر کانفیگ VLESS WebSocket +- **مدیریت اتصالات** — مدیریت inbound‌ها، کاربران، URI‌ها و حذف نصب سرورهای دیپلوی شده **بدون نیاز به نصب چیز اضافه.** فقط Python 3.8+ و یه فایل. @@ -357,12 +583,12 @@ python3 scanner.py --template 'vless://uuid@x:443?type=ws&...' -i addresses.txt **حالت‌های اسکن:** -| حالت | تعداد آی‌پی | پورت‌ها | زمان تقریبی | توضیحات | -|-----------|---------------|-------------|--------------|-------------------------------------------| -| Quick | ~۴,۰۰۰ | 443 | ~۳۰ ثانیه | ۱ آی‌پی تصادفی از هر بلاک /24 | -| Normal | ~۱۲,۰۰۰ | 443 | ~۲ دقیقه | ۳ آی‌پی از هر بلاک + تایید CF (پیشنهادی) | -| Full | ~۱,۵۰۰,۰۰۰ | 443 | ۲۰+ دقیقه | همه آی‌پی‌ها + تایید CF | -| Mega | ~۳,۰۰۰,۰۰۰ | 443 + 8443 | ۳۰-۶۰ دقیقه | همه آی‌پی‌ها روی ۲ پورت برای حداکثر پوشش | +| حالت | تعداد آی‌پی | پورت‌ها | زمان تقریبی | توضیحات | +|------|------------|---------|------------|---------| +| Quick | ~4,000 | 443 | ~30 ثانیه | 1 آی‌پی تصادفی از هر بلاک /24 | +| Normal | ~12,000 | 443 | ~2 دقیقه | 3 آی‌پی از هر بلاک + تایید CF (پیشنهادی) | +| Full | ~1,500,000 | 443 | 20+ دقیقه | همه آی‌پی‌ها + تایید CF | +| Mega | ~3,000,000 | 443+8443 | 30-60 دقیقه | همه آی‌پی‌ها روی 2 پورت | کلادفلر [۱۴ زیرشبکه IPv4](https://www.cloudflare.com/ips-v4/) منتشر کرده که مجموعاً **~۱,۵۱۱,۸۰۸ آی‌پی یکتا** هستن. حالت‌های Full و Mega همه رو اسکن می‌کنن. **حالت Mega** هر آی‌پی رو روی پورت 443 و 8443 (پورت جایگزین HTTPS کلادفلر) تست می‌کنه و تعداد پروب‌ها رو به **~۳ میلیون** می‌رسونه (۱.۵ میلیون آی‌پی × ۲ پورت). وقتی بعضی آی‌پی‌ها روی یه پورت مسدود هستن ولی روی پورت دیگه کار می‌کنن، این حالت خیلی مفیده. @@ -405,11 +631,11 @@ python3 scanner.py --find-clean --no-tui --clean-mode mega ### ⚙️ حالت‌های اسکن -| حالت | مراحل | حجم تقریبی | زمان تقریبی | مناسب برای | -|---------------------|----------------------|------------|--------------|---------------------| -| ⚡ سریع (Quick) | 1MB → 5MB | ~200 MB | ~2-3 دقیقه | بررسی سریع | -| 🔄 معمولی (Normal) | 1MB → 5MB → 20MB | ~850 MB | ~5-10 دقیقه | متعادل (پیشنهادی) | -| 🔬 دقیق (Thorough) | 5MB → 25MB → 50MB | ~5-10 GB | ~20-45 دقیقه | حداکثر دقت | +| حالت | مراحل | حجم تقریبی | زمان تقریبی | مناسب برای | +|------|-------|-----------|------------|-----------| +| Quick سریع | 1MB → 5MB | ~200 MB | ~2-3 دقیقه | بررسی سریع | +| Normal معمولی | 1MB → 5MB → 20MB | ~850 MB | ~5-10 دقیقه | متعادل (پیشنهادی) | +| Thorough دقیق | 5MB → 25MB → 50MB | ~5-10 GB | ~20-45 دقیقه | حداکثر دقت | --- @@ -417,18 +643,18 @@ python3 scanner.py --find-clean --no-tui --clean-mode mega بعد از اتمام اسکن، یه داشبورد تعاملی دارید: -| کلید | عملکرد | -|-------|--------------------------------------------------| -| `S` | 🔄 تغییر مرتب‌سازی: امتیاز → پینگ → سرعت | -| `C` | 📋 نمایش کانفیگ‌های VLESS/VMess یه آی‌پی | -| `D` | 🌐 نمایش دامنه‌های یه آی‌پی | -| `E` | 💾 خروجی گرفتن (CSV + بهترین N تا) | -| `A` | 📦 خروجی همه کانفیگ‌ها (مرتب شده) | -| `J/K` | ⬆️⬇️ بالا/پایین | -| `N/P` | 📄 صفحه بعد/قبل | -| `B` | ◀️ برگشت به منو (اسکن جدید) | -| `H` | ❓ راهنما | -| `Q` | 🚪 خروج | +| کلید | عملکرد | +|------|--------| +| `S` | تغییر مرتب‌سازی: امتیاز → پینگ → سرعت | +| `C` | نمایش کانفیگ‌های VLESS/VMess یه آی‌پی | +| `D` | نمایش دامنه‌های یه آی‌پی | +| `E` | خروجی گرفتن (CSV + بهترین N تا) | +| `A` | خروجی همه کانفیگ‌ها (مرتب شده) | +| `J/K` | بالا/پایین | +| `N/P` | صفحه بعد/قبل | +| `B` | برگشت به منو (اسکن جدید) | +| `H` | راهنما | +| `Q` | خروج | --- @@ -436,12 +662,12 @@ python3 scanner.py --find-clean --no-tui --clean-mode mega نتایج توی پوشه `results/` ذخیره میشن: -| فایل | محتوا | -|----------------------|-----------------------------------------| -| `*_results.csv` | فایل CSV با تمام اطلاعات | -| `*_top50.txt` | ۵۰ تا بهترین کانفیگ (آماده استفاده) | -| `*_full_sorted.txt` | همه کانفیگ‌ها مرتب شده | -| `clean_ips.txt` | آی‌پی‌های تمیز کلادفلر از IP Finder | +| فایل | محتوا | +|------|-------| +| `*_results.csv` | فایل CSV با تمام اطلاعات | +| `*_top50.txt` | 50 تا بهترین کانفیگ (آماده استفاده) | +| `*_full_sorted.txt` | همه کانفیگ‌ها مرتب شده | +| `clean_ips.txt` | آی‌پی‌های تمیز کلادفلر از IP Finder | --- @@ -455,6 +681,204 @@ cfray هوشمندانه با محدودیت‌های سرعت‌سنجی کلا --- +### 🆕 چه چیزهایی در v1.1 اضافه شده + +v1.1 قابلیت‌های **دیپلوی سرور**، **تست پایپلاین**، **پروکسی ورکر** و **مدیریت اتصالات** رو اضافه کرده — cfray رو از یه اسکنر به یه ابزار کامل پروکسی تبدیل کرده. + +--- + +### ⚡ تست پایپلاین Xray `[X]` + +یه پایپلاین هوشمند ۳ مرحله‌ای که یه کانفیگ VLESS/VMess می‌گیره و بهترین راه اتصال رو پیدا می‌کنه — شامل دور زدن DPI با فرگمنت TLS. + +**چطوری کار می‌کنه:** + +``` +مرحله ۱: 🔍 اسکن آی‌پی + رنج‌های آی‌پی کلادفلر رو اسکن می‌کنه تا آی‌پی‌های تمیز و قابل + دسترس پیدا کنه. هزاران آی‌پی رو همزمان با TLS handshake تست می‌کنه. + +مرحله ۲: 🧪 تست پایه + هر آی‌پی تمیز رو توی کانفیگتون جایگزین می‌کنه و تست می‌کنه که آیا + واقعاً ترافیک رد می‌کنه یا نه. از تانل مستقیم VLESS برای تایید + اتصال واقعی استفاده می‌کنه — نه فقط handshake. آی‌پی‌هایی که با + کانفیگ خاص شما کار می‌کنن فیلتر میشن. + +مرحله ۳: 🔧 گسترش فرگمنت + آی‌پی‌های کار‌کننده رو با پریست‌های فرگمنت DPI bypass و تغییرات + SNI گسترش میده: + - light: فرگمنت 100-200 بایت، فاصله 10-20 میلی‌ثانیه + - medium: فرگمنت 50-200 بایت، فاصله 10-40 میلی‌ثانیه + - heavy: فرگمنت 10-300 بایت، فاصله 5-50 میلی‌ثانیه + بهترین ترکیب آی‌پی + فرگمنت + SNI رو پیدا می‌کنه. +``` + +**کاربرد:** کانفیگی دارید که وصل نمیشه (مسدود شده توسط DPI/فایروال). به جای اینکه دستی آی‌پی و تنظیمات فرگمنت مختلف رو امتحان کنید، پایپلاین خودکار ترکیب‌های کار‌کننده رو پیدا می‌کنه. + +**xray-core** لازمه — cfray اولین بار خودش دانلود می‌کنه یا می‌تونید دستی نصب کنید: + +```bash +# نصب خودکار xray-core +python3 scanner.py --xray-install + +# پایپلاین تعاملی +python3 scanner.py # X رو بزنید + +# خط فرمان — تست یه کانفیگ خاص +python3 scanner.py --xray 'vless://uuid@domain:443?type=ws&security=tls#myconfig' + +# خط فرمان — فقط فرگمنت سنگین، ۵ تا برتر +python3 scanner.py --xray 'vless://...' --xray-frag heavy --xray-keep 5 +``` + +--- + +### 🚀 دیپلوی سرور Xray `[D]` + +یه سرور پروکسی Xray کاملاً پیکربندی‌شده رو روی هر VPS لینوکسی در کمتر از ۲ دقیقه نصب کنید. ویزارد شما رو قدم به قدم راهنمایی می‌کنه — پروتکل، ترنسپورت، امنیت، پورت — و URI کلاینت آماده استفاده تحویل میده. + +**چه چیزهایی نصب می‌کنه:** +- دانلود و نصب **xray-core** +- تولید کانفیگ سرور با پروتکل/ترنسپورت/امنیت دلخواه شما +- ساخت **سرویس systemd** برای اجرای خودکار +- دریافت **گواهی TLS** با certbot (برای امنیت TLS) +- تولید **کلید x25519** (برای امنیت REALITY) +- خروجی URI کلاینت VLESS/VMess که مستقیم وارد اپ می‌کنید + +**گزینه‌های پشتیبانی شده:** + +| دسته | گزینه‌ها | +|------|---------| +| پروتکل | VLESS, VMess | +| ترنسپورت | TCP, WebSocket, gRPC, HTTP/2, XHTTP | +| امنیت | REALITY, TLS, None | +| پورت | هر پورتی (پیشفرض 443) | + +> **توجه:** حالت REALITY فقط از TCP، gRPC و HTTP/2 پشتیبانی می‌کنه. WebSocket و XHTTP با امنیت TLS یا None در دسترس هستن. + +**REALITY** حالت امنیتی پیشنهادی هست — نیازی به دامنه یا گواهی TLS نداره. از تبادل کلید x25519 با SNI استتاری (مثل `yahoo.com` یا `google.com`) استفاده می‌کنه تا ترافیک شبیه HTTPS عادی به نظر بیاد. + +**XHTTP** (SplitHTTP) یه ترنسپورت سازگار با CDN هست که پشت کلادفلر و CDN‌های دیگه خوب کار می‌کنه. درخواست‌های HTTP رو طوری تقسیم می‌کنه که از بسیاری از سیستم‌های DPI رد بشه. + +**دیپلوی چند کانفیگ:** می‌تونید چندین کانفیگ با پروتکل‌های مختلف رو توی یه نشست نصب کنید. مثلاً TCP+REALITY روی پورت 443 *و* WS+TLS روی پورت 444 روی همون سرور. هر کانفیگ UUID و پورت خودش رو داره. کلیدهای REALITY و گواهی TLS یکبار تولید و بعد مجدداً استفاده میشن. + +```bash +# ویزارد تعاملی (پیشنهادی) +python3 scanner.py # D رو بزنید + +# خط فرمان — TCP+REALITY +python3 scanner.py --deploy --deploy-transport tcp --deploy-security reality + +# خط فرمان — WS+TLS با دامنه +python3 scanner.py --deploy --deploy-transport ws --deploy-security tls --deploy-sni yourdomain.com +``` + +**بعد از دیپلوی** یه منوی تعاملی دارید: +- **[V] مشاهده URI** — نمایش دوباره همه کانفیگ‌های تولید شده (دیگه ناپدید نمیشن) +- **[M] مدیریت اتصالات** — مستقیم به Connection Manager برید +- **[Q] برگشت** — برگشت به منوی اصلی + +--- + +### ☁️ پروکسی ورکر `[O]` + +یه **SNI تازه از Cloudflare Workers** برای هر کانفیگ VLESS بسازید. cfray یه اسکریپت Worker تولید می‌کنه که ترافیک WebSocket رو به سرور شما پروکسی می‌کنه — شما خودتون روی حساب کلادفلرتون دیپلوی می‌کنید و یه SNI تمیز `workers.dev` می‌گیرید. + +**چطوری کار می‌کنه:** +1. یه کانفیگ VLESS کار‌کننده میدید (ترنسپورت WebSocket لازمه) +2. cfray یه اسکریپت JavaScript Worker تولید می‌کنه که اتصالات WebSocket رو به سرور شما رله می‌کنه +3. cfray اسکریپت + راهنمای قدم به قدم دیپلوی روی `dash.cloudflare.com` رو نشون میده +4. آدرس Worker خودتون رو توی cfray وارد می‌کنید +5. cfray یه کانفیگ جدید با دامنه تازه `*.workers.dev` به عنوان SNI خروجی میده + +**کاربرد:** کانفیگتون کار می‌کنه ولی SNI/دامنه توی منطقه‌تون مسدود شده. به جای پیدا کردن دامنه جدید، یه پروکسی Workers می‌سازید که SNI تمیز `workers.dev` بهتون میده. چون دامنه‌های Workers کلادفلر برای کارهای قانونی زیادی استفاده میشن، به ندرت مسدود میشن. + +**نیازمندی‌ها:** +- حساب کلادفلر (تیر رایگان کافیه) +- کانفیگ اصلی باید ترنسپورت **WebSocket** داشته باشه + +```bash +# تعاملی +python3 scanner.py # O رو بزنید +``` + +--- + +### 🔧 مدیریت اتصالات (Connection Manager) `[C]` + +کانفیگ سرور Xray موجود رو مستقیم مدیریت کنید — اضافه/حذف inbound، مدیریت کاربران، مشاهده URI کلاینت‌ها و حذف نصب. + +**کلیدهای مدیریت:** + +| کلید | عملکرد | +|------|--------| +| `A` | **اضافه کردن inbound** — پروتکل/ترنسپورت/پورت جدید بسازید | +| `V` | **مشاهده** — جزئیات JSON اینباند رو ببینید | +| `U` | **اضافه کردن کاربر** — UUID جدید به یه inbound اضافه کنید | +| `X` | **حذف inbound** — یه inbound موجود رو پاک کنید | +| `S` | **نمایش URI** — همه URI‌های کلاینت برای همه کاربران | +| `R` | **ریستارت xray** — سرویس xray رو ریستارت کنید | +| `L` | **لاگ‌ها** — لاگ‌های سرویس xray رو ببینید | +| `D` | **حذف نصب** — xray، کانفیگ، systemd رو کامل پاک کنید | +| `B` | **برگشت** — برگشت به منوی اصلی | + +**نمایش URI** از فایل کانفیگ سرور، URI‌های VLESS/VMess کلاینت رو برای هر inbound و هر کاربر تولید می‌کنه. وقتی چندین کانفیگ دیپلوی کردید و باید URI‌ها رو با کاربران به اشتراک بذارید، یا وقتی URI‌های اصلی از زمان دیپلوی گم شدن، خیلی مفیده. + +**حذف نصب** هر چیزی که cfray نصب کرده رو کامل حذف می‌کنه: سرویس xray رو متوقف می‌کنه، باینری رو حذف می‌کنه، فایل‌های کانفیگ رو پاک می‌کنه، سرویس systemd رو حذف می‌کنه و پوشه `~/.cfray/` رو تمیز می‌کنه. برای ایمنی باید عبارت "uninstall" رو تایپ کنید. + +```bash +# تعاملی +python3 scanner.py # C رو بزنید +``` + +--- + +### 🔧 فلگ‌های جدید CLI در v1.1 + +| فلگ | توضیحات | پیشفرض | +|-----|---------|--------| +| `--xray URI` | تست URI از طریق پایپلاین xray | — | +| `--xray-frag` | پریست فرگمنت: `none`/`light`/`medium`/`heavy`/`all` | `all` | +| `--xray-bin PATH` | مسیر باینری xray | خودکار | +| `--xray-install` | دانلود و نصب xray-core در `~/.cfray/bin/` | خاموش | +| `--xray-keep N` | تعداد نتایج برتر پایپلاین | 10 | +| `--deploy` | دیپلوی سرور Xray روی VPS لینوکسی | — | +| `--deploy-port` | پورت سرور | 443 | +| `--deploy-protocol` | `vless` / `vmess` | `vless` | +| `--deploy-transport` | `tcp` / `ws` / `grpc` / `h2` | `tcp` | +| `--deploy-security` | `reality` / `tls` / `none` | `reality` | +| `--deploy-sni` | دامنه SNI برای TLS/REALITY | — | +| `--deploy-cert` | مسیر گواهی TLS | — | +| `--deploy-key` | مسیر کلید خصوصی TLS | — | +| `--deploy-ip` | جایگزین آی‌پی شناسایی خودکار سرور | خودکار | +| `--uninstall` | حذف کامل همه چیزهایی که cfray نصب کرده | خاموش | + +--- + +### 💡 مثال‌های بیشتر (v1.1) + +```bash +# نصب xray-core (برای تست پایپلاین لازمه) +python3 scanner.py --xray-install + +# تست پایپلاین — پیدا کردن ترکیب آی‌پی + فرگمنت برای کانفیگ مسدود شده +python3 scanner.py --xray 'vless://uuid@domain:443?type=ws&security=tls#config' + +# فقط فرگمنت سنگین، ۳ تا برتر +python3 scanner.py --xray 'vless://...' --xray-frag heavy --xray-keep 3 + +# دیپلوی سریع TCP+REALITY (پیشنهادی برای تازه‌کارها) +python3 scanner.py --deploy + +# دیپلوی WS+TLS برای مسیریابی CDN +python3 scanner.py --deploy --deploy-transport ws --deploy-security tls --deploy-sni example.com + +# حذف نصب کامل +python3 scanner.py --uninstall +``` + +--- +
### ⭐ Made by Sam — SamNet Technologies diff --git a/scanner.py b/scanner.py index 500b455..7bb2b58 100644 --- a/scanner.py +++ b/scanner.py @@ -2,7 +2,7 @@ # # ┌─────────────────────────────────────────────────────────────────┐ # │ │ -# │ ⚡ CF CONFIG SCANNER v1.0 │ +# │ ⚡ CF CONFIG SCANNER v1.1 │ # │ │ # │ Test VLESS/VMess proxy configs for latency + download speed │ # │ │ @@ -13,8 +13,11 @@ # │ • Clean IP Finder — scan all Cloudflare ranges (up to 3M) │ # │ • Multi-port scanning (443, 8443) for maximum coverage │ # │ • Zero dependencies — Python 3.8+ stdlib only │ +# │ • Xray Pipeline Test — smart probe → expand → speed test │ +# │ • Deploy Xray Server — full VPS setup with systemd + certs │ +# │ • Worker Proxy — fresh workers.dev SNI for any VLESS config │ # │ │ -# │ GitHub: https://github.com/SamNet-dev/cfray │ +# │ Repo: https://git.samnet.dev/SamNet-dev/cfray │ # │ │ # └─────────────────────────────────────────────────────────────────┘ # @@ -29,27 +32,34 @@ import asyncio import argparse import base64 +import copy import csv import glob as globmod +import http.client import ipaddress import json import os +import platform as _platform import random import re +import shutil import signal import socket import ssl import statistics +import subprocess import sys import time +import urllib.error import urllib.parse import urllib.request +import zipfile from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple -VERSION = "1.0" +VERSION = "1.1" SPEED_HOST = "speed.cloudflare.com" SPEED_PATH = "/__down" DEBUG_LOG = os.path.join("results", "debug.log") @@ -84,6 +94,134 @@ CLEAN_MODES = { "ports": [443, 8443], "desc": "All IPs × 2 ports (~3M probes, 30-60 min)"}, } +# ─── Xray Proxy Testing Constants ──────────────────────────────────────────── + +XRAY_HOME = os.path.join(os.path.expanduser("~"), ".cfray") +XRAY_BIN_DIR = os.path.join(XRAY_HOME, "bin") +XRAY_TMP_DIR = os.path.join(XRAY_HOME, "tmp") +XRAY_BASE_PORT = 10900 +XRAY_CONNECT_TIMEOUT = 8.0 +XRAY_QUICK_TIMEOUT = 10.0 +XRAY_QUICK_SIZE = 100_000 +XRAY_SPEED_TIMEOUT = 20.0 +XRAY_SPEED_SIZE = 5_000_000 +XRAY_PROFILES_DIR = os.path.join(XRAY_HOME, "profiles") +RESULTS_DIR = "results" + +_CF_PREFLIGHT_IPS = ["104.16.128.1", "198.41.192.1", "172.67.128.1"] + + +def _generate_random_cf_ips(count: int = 100) -> List[str]: + """Pick *count* random IPs, one per /24, spread across all CF ranges.""" + blocks = [] + for sub in CF_SUBNETS: + try: + net = ipaddress.IPv4Network(sub.strip(), strict=False) + if net.prefixlen <= 24: + blocks.extend(net.subnets(new_prefix=24)) + else: + blocks.append(net) + except (ValueError, TypeError): + continue + random.shuffle(blocks) + ips: List[str] = [] + for blk in blocks[:count]: + hosts = list(blk.hosts()) + ips.append(str(random.choice(hosts))) + return ips + + +CF_TEST_IPS = _generate_random_cf_ips(6666) +_CF_NETS = [ipaddress.IPv4Network(s, strict=False) for s in CF_SUBNETS] + + +def _is_cf_address(addr: str) -> bool: + """Check if an address falls within known Cloudflare IP ranges.""" + try: + ip = ipaddress.ip_address(addr) + return any(ip in net for net in _CF_NETS) + except (ValueError, TypeError): + return False + + +def _resolve_is_cf(addr: str) -> bool: + """Check if an address is behind Cloudflare.""" + if _is_cf_address(addr): + return True + try: + infos = socket.getaddrinfo(addr, None, socket.AF_INET, socket.SOCK_STREAM) + for _, _, _, _, (ip_str, _) in infos: + if _is_cf_address(ip_str): + return True + except (socket.gaierror, OSError): + pass + return False + + +XRAY_FRAG_PRESETS = { + "none": [None], + "light": [ + {"packets": "tlshello", "length": "100-200", "interval": "10-20"}, + ], + "medium": [ + {"packets": "tlshello", "length": "50-100", "interval": "10-20"}, + {"packets": "tlshello", "length": "100-200", "interval": "20-40"}, + ], + "heavy": [ + {"packets": "tlshello", "length": "10-50", "interval": "5-10"}, + {"packets": "tlshello", "length": "50-100", "interval": "10-30"}, + {"packets": "tlshello", "length": "100-300", "interval": "20-50"}, + ], + "all": [ + None, + {"packets": "tlshello", "length": "100-200", "interval": "10-20"}, + {"packets": "tlshello", "length": "50-100", "interval": "10-30"}, + {"packets": "tlshello", "length": "10-50", "interval": "5-10"}, + ], +} + +XRAY_CONFIG_TEMPLATE = { + "log": {"loglevel": "warning"}, + "inbounds": [{ + "tag": "socks", "port": XRAY_BASE_PORT, "listen": "127.0.0.1", + "protocol": "socks", + "settings": {"auth": "noauth", "udp": False}, + }], + "outbounds": [{ + "tag": "proxy", "protocol": "vless", + "settings": {"vnext": [{"address": "", "port": 443, "users": []}]}, + "streamSettings": {}, + }], +} + +# ─── Deploy Constants ──────────────────────────────────────────────────────── + +DEPLOY_XRAY_BIN = "/usr/local/bin/xray" +DEPLOY_XRAY_CONFIG = "/usr/local/etc/xray/config.json" +DEPLOY_XRAY_CONFIG_DIR = "/usr/local/etc/xray" +DEPLOY_XRAY_SHARE = "/usr/local/share/xray" +DEPLOY_XRAY_SERVICE = "/etc/systemd/system/xray.service" +DEPLOY_XRAY_BACKUP_DIR = "/usr/local/etc/xray/backups" + +DEPLOY_SYSTEMD_UNIT = """\ +[Unit] +Description=Xray Service +After=network.target nss-lookup.target + +[Service] +User=root +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE +NoNewPrivileges=true +ExecStart=/usr/local/bin/xray run -config /usr/local/etc/xray/config.json +Restart=on-failure +RestartPreventExitStatus=23 +LimitNOFILE=1000000 + +[Install] +WantedBy=multi-user.target +""" + PRESETS = { "quick": { "label": "Quick", @@ -492,6 +630,100 @@ class State: self.latency_cut_n = 0 # how many IPs were cut after latency phase +@dataclass +class XrayVariation: + """One test variation = specific SNI + fragment combo.""" + tag: str + sni: str + fragment: Optional[dict] + config_json: dict + source_uri: str + alive: bool = False + connect_ms: float = -1 + ttfb_ms: float = -1 + speed_mbps: float = -1 + error: str = "" + score: float = 0 + result_uri: str = "" + native_tested: bool = False + + +class XrayTestState: + """State for xray proxy testing progress.""" + def __init__(self): + self.variations: List[XrayVariation] = [] + self.phase = "init" + self.phase_label = "" + self.total = 0 + self.done_count = 0 + self.alive_count = 0 + self.dead_count = 0 + self.best_speed = 0.0 + self.start_time = 0.0 + self.finished = False + self.interrupted = False + self.source_uri = "" + self.xray_bin = "" + self.export_error = "" + self.quick_passed = 0 + # Pipeline stage tracking + self.pipeline_mode = False + self.pipeline_stage = 0 + self.pipeline_stages = [ + {"name": "ip_scan", "label": "IP Scan", "status": "pending"}, + {"name": "base_test", "label": "Base Connectivity", "status": "pending"}, + {"name": "expansion", "label": "Expansion", "status": "pending"}, + ] + self.live_ips: List[Tuple[str, float]] = [] + self.live_ip_ports: dict = {} # {ip: [port1, port2, ...]} + self.working_ips: List[str] = [] + self.preflight_is_cf: Optional[bool] = None + self.preflight_warning: str = "" + self.cf_origin_errors: int = 0 + + +@dataclass +class PipelineConfig: + """Configuration for the progressive xray pipeline.""" + uri: str + parsed: dict + sni_pool: List[str] = field(default_factory=list) + frag_preset: str = "all" + transport_variants: List[str] = field(default_factory=list) + max_stage2_ips: int = 120 + max_expansion: int = 1000 + max_snis_per_ip: int = 20 + configless: bool = False + base_uris: List[Tuple[str, dict]] = field(default_factory=list) + custom_ips: List[str] = field(default_factory=list) + probe_ports: List[int] = field(default_factory=lambda: [443]) + + +class DeployState: + """State for Xray server deployment (Linux only).""" + def __init__(self): + self.source_uris: List[str] = [] + self.parsed_configs: List[dict] = [] + self.fresh_mode = False + + self.server_config: dict = {} + self.client_uris: List[str] = [] + + self.server_ip = "" + self.listen_port = 443 + + self.reality_private_key = "" + self.reality_public_key = "" + self.reality_short_id = "" + self.tls_cert_path = "" + self.tls_key_path = "" + self.tls_domain = "" + + self.phase = "init" + self.steps_done: List[str] = [] + self.error = "" + + class CFRateLimiter: """Respects Cloudflare's per-IP rate limit window. @@ -681,6 +913,2181 @@ def parse_config(uri: str) -> Optional[ConfigEntry]: return parse_vless(uri) or parse_vmess(uri) +def _infer_orig_sni(parsed: dict) -> str: + """Infer the original TLS SNI from a parsed config. + + Priority: explicit sni > address domain > host > address. + When no explicit sni is set, xray-core uses the vnext address as + serverName. If the address is a domain (not an IP), that domain IS the + TLS SNI. This matters for CDN domain-fronting configs where the address + domain (front) differs from the host domain (real origin). + When the address is an IP, fall back to host (most clients infer SNI + from the host field in that case). + """ + if parsed.get("sni"): + return parsed["sni"] + addr = parsed.get("address", "") + # If address is a domain (not an IP), it's the actual TLS SNI + try: + ipaddress.ip_address(addr) + except (ValueError, TypeError): + if addr: + return addr + # Address is an IP — fall back to host + return parsed.get("host") or addr + + +def parse_vless_full(uri: str) -> Optional[dict]: + """Parse VLESS URI into all component fields for Xray config generation.""" + uri = uri.strip() + if not uri.startswith("vless://"): + return None + rest = uri[8:] + name = "" + if "#" in rest: + rest, name = rest.split("#", 1) + name = urllib.parse.unquote(name) + params_str = "" + if "?" in rest: + rest, params_str = rest.split("?", 1) + if "@" not in rest: + return None + uuid_part, addr_part = rest.split("@", 1) + if not uuid_part or len(uuid_part) < 8: + return None + if addr_part.startswith("["): + if "]" not in addr_part: + return None + bracket_end = addr_part.index("]") + address = addr_part[1:bracket_end] + port_str = addr_part[bracket_end + 2:] if len(addr_part) > bracket_end + 1 and addr_part[bracket_end + 1] == ":" else "443" + else: + parts = addr_part.rsplit(":", 1) + address = parts[0] + port_str = parts[1] if len(parts) > 1 and parts[1].isdigit() else "443" + if not address: + return None + try: + port = int(port_str) + if not (1 <= port <= 65535): + port = 443 + except ValueError: + port = 443 + params = dict(urllib.parse.parse_qsl(params_str, keep_blank_values=True)) + security = params.get("security") or "none" + return { + "protocol": "vless", + "uuid": uuid_part, + "address": address, + "port": port, + "name": name, + "type": params.get("type") or "tcp", + "security": security, + "sni": params.get("sni") or "", + "host": params.get("host") or "", + "path": params.get("path") or "/", + "fp": params.get("fp") or "", + "flow": params.get("flow") or "", + "alpn": params.get("alpn") or "", + "encryption": params.get("encryption") or "none", + "serviceName": params.get("serviceName", ""), + "headerType": params.get("headerType", ""), + "pbk": params.get("pbk", ""), + "sid": params.get("sid", ""), + "spx": params.get("spx", ""), + "mode": params.get("mode") or "auto", + } + + +def parse_vmess_full(uri: str) -> Optional[dict]: + """Parse VMess base64 URI into all component fields for Xray config generation.""" + uri = uri.strip() + if not uri.startswith("vmess://"): + return None + b64 = uri[8:] + if "#" in b64: + b64 = b64.split("#", 1)[0] + b64 += "=" * (-len(b64) % 4) + try: + try: + raw = base64.b64decode(b64).decode("utf-8", errors="replace") + except ValueError: + raw = base64.urlsafe_b64decode(b64).decode("utf-8", errors="replace") + obj = json.loads(raw) + if not isinstance(obj, dict): + return None + except (ValueError, TypeError): + return None + address = str(obj.get("add", "")) + if not address: + return None + try: + port = int(obj.get("port", 443)) + if not (1 <= port <= 65535): + port = 443 + except (ValueError, TypeError): + port = 443 + try: + aid = int(obj.get("aid", 0)) + except (ValueError, TypeError): + aid = 0 + uuid_val = str(obj.get("id", "")) + if not uuid_val or len(uuid_val) < 8: + return None + tls_val = str(obj.get("tls") or "") + return { + "protocol": "vmess", + "uuid": uuid_val, + "address": address, + "port": port, + "name": str(obj.get("ps") or ""), + "type": str(obj.get("net") or "tcp"), + "security": "tls" if tls_val.lower() == "tls" else "none", + "sni": str(obj.get("sni") or ""), + "host": str(obj.get("host") or ""), + "path": str(obj.get("path") or "/"), + "fp": str(obj.get("fp") or ""), + "aid": aid, + "scy": str(obj.get("scy") or "auto"), + "alpn": str(obj.get("alpn") or ""), + "headerType": str(obj.get("type") or ""), + "mode": str(obj.get("mode") or "auto"), + } + + +def build_xray_config(parsed: dict, sni: str, fragment: Optional[dict], port: int, + address_override: str = "") -> dict: + """Build a complete Xray JSON config from parsed URI fields.""" + cfg = copy.deepcopy(XRAY_CONFIG_TEMPLATE) + cfg["inbounds"][0]["port"] = port + + is_vmess = parsed.get("protocol") == "vmess" + protocol = "vmess" if is_vmess else "vless" + _addr = address_override or parsed["address"] + + outbound = cfg["outbounds"][0] + outbound["protocol"] = protocol + + if is_vmess: + outbound["settings"] = {"vnext": [{ + "address": _addr, + "port": parsed["port"], + "users": [{ + "id": parsed["uuid"], + "alterId": parsed.get("aid", 0), + "security": parsed.get("scy", "auto"), + }], + }]} + else: + user = { + "id": parsed["uuid"], + "encryption": parsed.get("encryption", "none"), + } + flow = parsed.get("flow", "") + if flow: + user["flow"] = flow + outbound["settings"] = {"vnext": [{ + "address": _addr, + "port": parsed["port"], + "users": [user], + }]} + + net = parsed.get("type", "tcp") + sec = parsed.get("security", "tls") + host = parsed.get("host") or sni + + stream: dict = {"network": net, "security": sec} + + if sec == "tls": + tls_cfg: dict = { + "serverName": sni, + "allowInsecure": False, + } + _fp = parsed.get("fp", "") + if _fp: + tls_cfg["fingerprint"] = _fp + if parsed.get("alpn"): + tls_cfg["alpn"] = parsed["alpn"].split(",") + stream["tlsSettings"] = tls_cfg + elif sec == "reality": + stream["realitySettings"] = { + "serverName": sni, + "fingerprint": parsed.get("fp", "chrome"), + "publicKey": parsed.get("pbk", ""), + "shortId": parsed.get("sid", ""), + "spiderX": parsed.get("spx", ""), + } + + if net == "ws": + stream["wsSettings"] = { + "path": parsed.get("path", "/"), + "host": host, + "headers": {"Host": host}, + } + elif net == "grpc": + grpc_cfg: dict = { + "serviceName": parsed.get("serviceName") or (parsed.get("path", "") if parsed.get("path", "") != "/" else ""), + } + if host: + grpc_cfg["authority"] = host + stream["grpcSettings"] = grpc_cfg + elif net in ("h2", "http"): + stream["httpSettings"] = { + "host": [host], + "path": parsed.get("path", "/"), + } + elif net == "tcp": + htype = parsed.get("headerType", "") + if htype == "http": + stream["tcpSettings"] = {"header": { + "type": "http", + "request": { + "path": [parsed.get("path", "/")], + "headers": {"Host": [host]}, + }, + }} + elif net in ("xhttp", "splithttp"): + xhttp_cfg = {"path": parsed.get("path", "/xhttp")} + if host: + xhttp_cfg["host"] = host + mode = parsed.get("mode", "auto") + if mode and mode != "auto": + xhttp_cfg["mode"] = mode + stream["network"] = "xhttp" + stream["xhttpSettings"] = xhttp_cfg + + if fragment: + sockopt: dict = { + "dialerProxy": "fragment", + "tcpKeepAliveIdle": 300, + } + if sys.platform == "linux": + sockopt["mark"] = 255 + stream["sockopt"] = sockopt + cfg["outbounds"].append({ + "tag": "fragment", + "protocol": "freedom", + "settings": {"fragment": fragment}, + }) + + outbound["streamSettings"] = stream + return cfg + + +def build_vless_uri(parsed: dict, sni: str, tag: str) -> str: + """Reconstruct a VLESS URI with a specific SNI domain.""" + security = parsed.get("security", "tls") + params = { + "type": parsed.get("type", "tcp"), + "security": security, + "sni": sni, + } + _fp = parsed.get("fp", "") + if _fp: + params["fp"] = _fp + if (parsed.get("type") in ("ws", "h2", "http", "xhttp", "splithttp", "grpc") + or (parsed.get("type") == "tcp" and parsed.get("headerType") == "http") + or parsed.get("host")): + params["host"] = parsed.get("host") or sni + if parsed.get("path") and parsed["path"] != "/": + params["path"] = parsed["path"] + if parsed.get("flow"): + params["flow"] = parsed["flow"] + if parsed.get("alpn"): + params["alpn"] = parsed["alpn"] + if parsed.get("encryption") and parsed["encryption"] != "none": + params["encryption"] = parsed["encryption"] + if parsed.get("pbk"): + params["pbk"] = parsed["pbk"] + if parsed.get("sid"): + params["sid"] = parsed["sid"] + if parsed.get("spx"): + params["spx"] = parsed["spx"] + sn = parsed.get("serviceName") or "" + if not sn and parsed.get("type") == "grpc": + sn = parsed.get("path", "") + if sn == "/": + sn = "" + if sn: + params["serviceName"] = sn + if parsed.get("headerType") and parsed["headerType"] != "none": + params["headerType"] = parsed["headerType"] + if parsed.get("mode") and parsed["mode"] != "auto": + params["mode"] = parsed["mode"] + qs = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) + name = urllib.parse.quote(tag) + addr = parsed["address"] + if ":" in addr: + addr = f"[{addr}]" + return f"vless://{parsed['uuid']}@{addr}:{parsed['port']}?{qs}#{name}" + + +def build_vmess_uri(parsed: dict, sni: str, tag: str) -> str: + """Reconstruct a VMess base64 URI with a specific SNI domain.""" + obj = { + "v": "2", + "ps": tag, + "add": parsed["address"], + "port": str(parsed["port"]), + "id": parsed["uuid"], + "aid": str(parsed.get("aid", 0)), + "scy": parsed.get("scy", "auto"), + "net": parsed.get("type", "tcp"), + "type": parsed.get("headerType") or "none", + "host": parsed.get("host") or sni, + "path": parsed.get("path", "/"), + "tls": "tls" if parsed.get("security", "") == "tls" else "", + "sni": sni, + "alpn": parsed.get("alpn", ""), + "fp": parsed.get("fp", ""), + } + if parsed.get("type") in ("xhttp", "splithttp"): + obj["mode"] = parsed.get("mode", "auto") + if parsed.get("type") == "grpc": + obj["path"] = parsed.get("serviceName") or parsed.get("path", "grpc") + raw = json.dumps(obj, separators=(",", ":")) + b64 = base64.b64encode(raw.encode()).decode() + return f"vmess://{b64}" + + +def _build_uri(parsed: dict, sni: str, tag: str) -> str: + """Build VLESS or VMess URI based on the protocol field in parsed dict.""" + if parsed.get("protocol") == "vmess": + return build_vmess_uri(parsed, sni, tag) + return build_vless_uri(parsed, sni, tag) + + +def switch_transport(parsed: dict, new_transport: str, path: str = "") -> dict: + """Clone parsed config and change its transport type.""" + new = copy.deepcopy(parsed) + + # Only carry over path if it looks custom (not a transport default) + _default_paths = {"/", "/ws", "/xhttp", "/h2", "/grpc", "grpc"} + old_path = parsed.get("path", "/") + carry_path = old_path if old_path not in _default_paths else "" + + # XTLS flow (e.g. xtls-rprx-vision) only works with TCP — clear for others + if new_transport != "tcp" and new.get("flow"): + new["flow"] = "" + + if new_transport == "ws": + new["type"] = "ws" + new["path"] = path or carry_path or "/ws" + new["headerType"] = "" + new.pop("mode", None) + new.pop("serviceName", None) + elif new_transport in ("xhttp", "splithttp"): + new["type"] = "xhttp" + new["path"] = path or carry_path or "/xhttp" + new["mode"] = parsed.get("mode", "auto") + new["headerType"] = "" + new.pop("serviceName", None) + elif new_transport == "grpc": + new["type"] = "grpc" + svc = path or parsed.get("serviceName") or "grpc" + if svc.startswith("/"): + svc = svc[1:] + new["serviceName"] = svc + new["path"] = "" + new["headerType"] = "" + new.pop("mode", None) + elif new_transport in ("h2", "http"): + new["type"] = "h2" + new["path"] = path or carry_path or "/h2" + new["headerType"] = "" + new.pop("mode", None) + new.pop("serviceName", None) + elif new_transport == "tcp": + new["type"] = "tcp" + new["path"] = "/" + new["headerType"] = "" + new.pop("mode", None) + new.pop("serviceName", None) + # VLESS+REALITY+TCP requires XTLS flow + if (new.get("security") == "reality" + and new.get("protocol", "vless") == "vless" + and not new.get("flow")): + new["flow"] = "xtls-rprx-vision" + else: + return new + + return new + + +# ─── Input Helpers ──────────────────────────────────────────────────────── + + +def _flush_stdin(): + """Drain any stale bytes from stdin (e.g. leftover from multi-line paste).""" + if sys.platform == "win32": + import msvcrt + time.sleep(0.05) # let paste buffer settle + while msvcrt.kbhit(): + msvcrt.getwch() # getwch avoids echo + else: + import select + fd = sys.stdin.fileno() + while select.select([sys.stdin], [], [], 0.0)[0]: + os.read(fd, 4096) + + +def _restore_console_input(): + """Restore Windows console input mode for proper input() line editing. + + After TUI raw-key reads (msvcrt.getch), the console input mode flags + may be stripped. Re-enable ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | + ENABLE_PROCESSED_INPUT so that input() works with arrow keys, backspace, + etc. No-op on non-Windows. + """ + if sys.platform != "win32": + return + try: + import ctypes + k = ctypes.windll.kernel32 + h = k.GetStdHandle(-10) # STD_INPUT_HANDLE + m = ctypes.c_ulong() + k.GetConsoleMode(h, ctypes.byref(m)) + # ENABLE_LINE_INPUT=0x2 | ENABLE_ECHO_INPUT=0x4 | ENABLE_PROCESSED_INPUT=0x1 + need = 0x0007 + if (m.value & need) != need: + k.SetConsoleMode(h, m.value | need) + except (OSError, ValueError): + pass + + +# ─── Xray Binary & Process Management ──────────────────────────────────── + + +def xray_find_binary(custom_path: Optional[str] = None) -> Optional[str]: + """Find xray binary. Search order: custom_path > PATH > ~/.cfray/bin/xray.""" + if custom_path and os.path.isfile(custom_path): + return os.path.abspath(custom_path) + xray_name = "xray.exe" if sys.platform == "win32" else "xray" + found = shutil.which(xray_name) + if found: + return found + local_bin = os.path.join(XRAY_BIN_DIR, xray_name) + if os.path.isfile(local_bin): + return local_bin + return None + + +def xray_install() -> Optional[str]: + """Download xray-core to ~/.cfray/bin/. Returns binary path or None.""" + os.makedirs(XRAY_BIN_DIR, exist_ok=True) + machine = _platform.machine().lower() + if sys.platform == "win32": + if "aarch64" in machine or "arm64" in machine: + asset_name = "Xray-windows-arm64-v8a.zip" + elif "64" in machine or "amd64" in machine: + asset_name = "Xray-windows-64.zip" + else: + asset_name = "Xray-windows-32.zip" + elif sys.platform == "darwin": + if "arm" in machine or "aarch64" in machine: + asset_name = "Xray-macos-arm64-v8a.zip" + else: + asset_name = "Xray-macos-64.zip" + else: + if "aarch64" in machine or "arm64" in machine: + asset_name = "Xray-linux-arm64-v8a.zip" + elif "arm" in machine: + asset_name = "Xray-linux-arm32-v7a.zip" + else: + asset_name = "Xray-linux-64.zip" + + url = f"https://github.com/XTLS/Xray-core/releases/latest/download/{asset_name}" + zip_path = os.path.join(XRAY_BIN_DIR, asset_name) + + print(f" Downloading {asset_name}...") + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=120) as resp: + with open(zip_path, "wb") as f: + while True: + chunk = resp.read(65536) + if not chunk: + break + f.write(chunk) + except (OSError, ValueError, http.client.HTTPException) as e: + print(f" Download failed: {e}") + try: + os.remove(zip_path) + except OSError: + pass + return None + + try: + with zipfile.ZipFile(zip_path, "r") as zf: + real_base = os.path.realpath(XRAY_BIN_DIR) + for info in zf.infolist(): + target = os.path.realpath(os.path.join(XRAY_BIN_DIR, info.filename)) + if target != real_base and not target.startswith(real_base + os.sep): + print(f" Bad zip entry (path traversal): {info.filename}") + return None + zf.extractall(XRAY_BIN_DIR) + except (zipfile.BadZipFile, OSError) as e: + print(f" Extract failed: {e}") + return None + finally: + try: + os.remove(zip_path) + except OSError: + pass + + xray_name = "xray.exe" if sys.platform == "win32" else "xray" + bin_path = os.path.join(XRAY_BIN_DIR, xray_name) + if sys.platform != "win32": + try: + os.chmod(bin_path, 0o755) + except OSError: + pass + if os.path.isfile(bin_path): + print(f" Installed to {bin_path}") + return bin_path + return None + + +def _find_free_ports(base: int, count: int) -> List[int]: + """Find `count` free TCP ports starting from `base`.""" + ports: List[int] = [] + port = base + while len(ports) < count and port <= 65535: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", port)) + ports.append(port) + except OSError: + pass + finally: + s.close() + port += 1 + return ports + + +class XrayProcess: + """Manages a single xray-core subprocess.""" + + def __init__(self, binary: str, config_path: str, socks_port: int): + self.binary = binary + self.config_path = config_path + self.socks_port = socks_port + self.proc: Optional[subprocess.Popen] = None + self.last_error: str = "" + + def _read_stderr_file(self): + """Read last error from stderr temp file (tail).""" + try: + p = self.config_path + ".err" + if not os.path.isfile(p): + self.last_error = "no-stderr-file" + return + sz = os.path.getsize(p) + if sz == 0: + self.last_error = "xray-stderr-empty" + return + with open(p, "r", encoding="utf-8", errors="replace") as f: + # Read last 32KB to capture debug-level output + if sz > 32768: + f.seek(sz - 32768) + f.readline() # skip partial first line + lines = f.read().strip().splitlines() + if not lines: + self.last_error = f"xray-stderr-{sz}B-no-lines" + return + # Search backward for meaningful error lines + for line in reversed(lines): + lo = line.lower() + if any(kw in lo for kw in ( + "error", "fail", "reject", "refused", "timeout", + "closed", "reset", "eof", "tls:", "dial", + "handshake", "certificate", "invalid")): + self.last_error = line.strip()[-120:] + return + # No keyword match — show last non-empty line + file stats + self.last_error = f"[{sz}B/{len(lines)}L] {lines[-1].strip()[-80:]}" + except OSError: + pass + + def start(self) -> bool: + """Start xray process. Returns True if SOCKS5 port becomes reachable.""" + err_path = self.config_path + ".err" + try: + err_fd = os.open(err_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + kwargs: dict = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": err_fd, + } + if sys.platform == "win32": + kwargs["creationflags"] = 0x08000000 # CREATE_NO_WINDOW + self.proc = subprocess.Popen( + [self.binary, "run", "-c", self.config_path], + **kwargs, + ) + os.close(err_fd) + except (OSError, ValueError, subprocess.SubprocessError): + try: + os.close(err_fd) + except (OSError, UnboundLocalError): + pass + return False + + deadline = time.monotonic() + XRAY_CONNECT_TIMEOUT + while time.monotonic() < deadline: + if self.proc.poll() is not None: + self._read_stderr_file() + return False + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.settimeout(0.5) + s.connect(("127.0.0.1", self.socks_port)) + s.close() + return True + except (ConnectionRefusedError, socket.timeout, OSError): + s.close() + time.sleep(0.3) + self._read_stderr_file() + self.stop() + return False + + def stop(self): + """Terminate xray process and cleanup.""" + if self.proc: + self._read_stderr_file() + try: + self.proc.terminate() + except OSError: + pass + try: + self.proc.wait(timeout=3) + except (subprocess.TimeoutExpired, OSError): + try: + self.proc.kill() + except OSError: + pass + try: + self.proc.wait(timeout=2) + except (subprocess.TimeoutExpired, OSError): + pass + self.proc = None + + def cleanup(self): + """Remove temp config and stderr files.""" + for p in (self.config_path, self.config_path + ".err"): + try: + if os.path.isfile(p): + os.remove(p) + except OSError: + pass + + +# ─── SOCKS5 Speed Test (stdlib only) ─────────────────────────────────────── + + +def _xray_speed_test_blocking( + socks_port: int, size: int, timeout: float, +) -> Tuple[float, float, float, str]: + """Blocking SOCKS5 + TLS + HTTP download. Returns (connect_ms, ttfb_ms, speed_mbps, error).""" + sock = None + tls_sock = None + try: + t0 = time.monotonic() + + # 1) Connect to SOCKS5 proxy + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(("127.0.0.1", socks_port)) + + def _recv_exact(s, n): + """Receive exactly n bytes from socket.""" + buf = bytearray() + while len(buf) < n: + chunk = s.recv(n - len(buf)) + if not chunk: + raise ConnectionError("connection closed during recv") + buf.extend(chunk) + return bytes(buf) + + # SOCKS5 handshake (no auth) + sock.sendall(b"\x05\x01\x00") + resp = _recv_exact(sock, 2) + if resp != b"\x05\x00": + return -1, -1, 0, f"socks5-auth:{resp.hex()}" + + # SOCKS5 CONNECT to speed.cloudflare.com:443 + host = SPEED_HOST.encode("ascii") + req = b"\x05\x01\x00\x03" + bytes([len(host)]) + host + (443).to_bytes(2, "big") + sock.sendall(req) + head = _recv_exact(sock, 4) + if head[1] != 0: + return -1, -1, 0, f"socks5-connect:{head[1]}" + atyp = head[3] + if atyp == 1: + _recv_exact(sock, 6) + elif atyp == 3: + dlen = _recv_exact(sock, 1)[0] + _recv_exact(sock, dlen + 2) + elif atyp == 4: + _recv_exact(sock, 18) + else: + return -1, -1, 0, f"socks5-bad-atyp:{atyp}" + + # 2) TLS upgrade + ctx = ssl.create_default_context() + tls_sock = ctx.wrap_socket(sock, server_hostname=SPEED_HOST) + sock = None # tls_sock now owns the socket + connect_ms = (time.monotonic() - t0) * 1000 + + # 3) HTTP request + http_req = ( + f"GET {SPEED_PATH}?bytes={size} HTTP/1.0\r\n" + f"Host: {SPEED_HOST}\r\n" + f"User-Agent: Mozilla/5.0\r\n\r\n" + ).encode() + tls_sock.sendall(http_req) + + # Read headers + hbuf = b"" + hdr_deadline = time.monotonic() + timeout + while b"\r\n\r\n" not in hbuf: + if time.monotonic() > hdr_deadline: + return connect_ms, -1, 0, "hdr-timeout" + ch = tls_sock.recv(4096) + if not ch: + return connect_ms, -1, 0, "empty-headers" + hbuf += ch + if len(hbuf) > 65536: + return connect_ms, -1, 0, "hdr-too-big" + + sep_idx = hbuf.index(b"\r\n\r\n") + 4 + htxt = hbuf[:sep_idx].decode("latin-1", errors="replace") + body0 = hbuf[sep_idx:] + + status_parts = htxt.split("\r\n")[0].split(None, 2) + status_code = status_parts[1] if len(status_parts) >= 2 else "" + if status_code not in ("200", "206"): + return connect_ms, -1, 0, f"http:{status_code}" + + ttfb_ms = (time.monotonic() - t0) * 1000 - connect_ms + + # 4) Download body (seed with body bytes already in header buffer) + dl_start = time.monotonic() + dl_deadline = dl_start + timeout + total = len(body0) + while True: + try: + if time.monotonic() > dl_deadline: + break + ch = tls_sock.recv(65536) + if not ch: + break + total += len(ch) + except socket.timeout: + break + except ssl.SSLWantReadError: + continue + except (OSError, ssl.SSLError): + break + + dl_t = time.monotonic() - dl_start + if total < min(size * 0.05, 4096): + return connect_ms, ttfb_ms, 0, f"incomplete:{total}" + mbps = (total / 1_000_000) / dl_t if dl_t > 0.001 else 0 + return connect_ms, ttfb_ms, mbps, "" + + except socket.timeout: + return -1, -1, 0, "timeout" + except (OSError, ssl.SSLError) as e: + return -1, -1, 0, str(e)[:60] + finally: + if tls_sock: + try: + tls_sock.close() + except OSError: + pass + if sock: + try: + sock.close() + except OSError: + pass + + +async def xray_speed_test( + socks_port: int, size: int, timeout: float, +) -> Tuple[float, float, float, str]: + """Async wrapper: runs blocking SOCKS5 speed test in executor.""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, _xray_speed_test_blocking, socks_port, size, timeout, + ) + + +# ─── Python-native VLESS-over-WS speed test ─────────────────────────────── + + +def _ws_frame_encode(data: bytes, opcode: int = 0x02) -> bytes: + """Encode a masked WebSocket binary frame (client->server).""" + import secrets as _sec + mask = _sec.token_bytes(4) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + length = len(data) + if length <= 125: + header = bytes([0x80 | opcode, 0x80 | length]) + elif length <= 65535: + header = bytes([0x80 | opcode, 0xFE]) + length.to_bytes(2, 'big') + else: + header = bytes([0x80 | opcode, 0xFF]) + length.to_bytes(8, 'big') + return header + mask + masked + + +class _WsFrameParser: + """Incremental WebSocket frame parser for server->client (unmasked) frames.""" + __slots__ = ('_buf',) + + def __init__(self, initial: bytes = b""): + self._buf = bytearray(initial) + + def feed(self, data: bytes) -> None: + self._buf.extend(data) + + def next_frame(self) -> Optional[Tuple[int, bytes]]: + """Extract next complete frame. Returns (opcode, payload) or None.""" + buf = self._buf + if len(buf) < 2: + return None + opcode = buf[0] & 0x0F + masked = bool(buf[1] & 0x80) + plen = buf[1] & 0x7F + off = 2 + if plen == 126: + if len(buf) < 4: + return None + plen = int.from_bytes(buf[2:4], 'big') + off = 4 + elif plen == 127: + if len(buf) < 10: + return None + plen = int.from_bytes(buf[2:10], 'big') + off = 10 + if masked: + if len(buf) < off + 4: + return None + off += 4 + if len(buf) < off + plen: + return None + payload = bytes(buf[off:off + plen]) + if masked: + mask_key = bytes(buf[off - 4:off]) + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + del self._buf[:off + plen] + return (opcode, payload) + + @property + def buffered(self) -> int: + return len(self._buf) + + +def _extract_vless_ws_params(config_json: dict) -> Optional[dict]: + """Extract VLESS-over-WS params from xray config JSON. + + Returns dict with ip, port, uuid, sni, host, path, security -- or None + if this config is not a VLESS+WS combination. + """ + outbounds = config_json.get("outbounds", []) + if not outbounds: + return None + out = outbounds[0] + if out.get("protocol") != "vless": + return None + stream = out.get("streamSettings", {}) + if stream.get("network") not in ("ws", "websocket"): + return None + vnext = out.get("settings", {}).get("vnext", [{}]) + if not vnext: + return None + vnext0 = vnext[0] + users = vnext0.get("users", [{}]) + if not users: + return None + ws = stream.get("wsSettings", {}) + tls = stream.get("tlsSettings", {}) + security = stream.get("security", "none") + return { + "ip": vnext0.get("address", ""), + "port": int(vnext0.get("port", 443)), + "uuid": users[0].get("id", ""), + "sni": tls.get("serverName", ""), + "host": ws.get("headers", {}).get("Host", "") or ws.get("host", ""), + "path": ws.get("path", "/"), + "security": security, + } + + +async def _vless_ws_read_tunnel( + reader: asyncio.StreamReader, wsp: _WsFrameParser, + vless_hdr_done: bool, timeout: float = 5.0, +) -> Tuple[bytes, bool, bool, str]: + """Read next non-empty data chunk from VLESS tunnel. + + Strips WS framing and VLESS response header automatically. + Loops internally until real data arrives or the connection closes. + + Returns (data, vless_hdr_done, closed, reason). + - data: decapsulated tunnel bytes (non-empty unless closed) + - vless_hdr_done: updated flag + - closed: True if connection ended + - reason: error description when closed (empty string otherwise) + """ + while True: + frame = wsp.next_frame() + if frame is not None: + op, payload = frame + if op == 8: + cc = int.from_bytes(payload[:2], 'big') \ + if len(payload) >= 2 else 0 + return b"", vless_hdr_done, True, f"ws-close:{cc}" + if op not in (0, 2): + continue + # Strip VLESS v0 response header from first data frame + if not vless_hdr_done: + if len(payload) >= 2 and payload[0] == 0x00: + addon_len = payload[1] + payload = payload[2 + addon_len:] + vless_hdr_done = True + elif payload: + return (b"", vless_hdr_done, True, + f"vless-bad:{payload[:6].hex()}") + else: + continue # empty frame, keep reading + # Skip empty payloads (e.g. VLESS header was in its own frame) + if not payload: + continue + return payload, vless_hdr_done, False, "" + + # Need more data from network + try: + chunk = await asyncio.wait_for( + reader.read(65536), timeout=timeout) + except asyncio.TimeoutError: + return b"", vless_hdr_done, True, "tunnel-timeout" + except (OSError, ssl.SSLError) as e: + return b"", vless_hdr_done, True, f"tunnel:{str(e)[:30]}" + if not chunk: + return b"", vless_hdr_done, True, "tunnel-eof" + wsp.feed(chunk) + + +async def _vless_ws_speed_test( + ip: str, port: int, sni: str, host: str, ws_path: str, + uuid_str: str, size: int, timeout: float, + security: str = "tls", +) -> Tuple[float, float, float, str]: + """Python-native VLESS-over-WS speed test -- no xray binary needed. + + Two modes based on download size: + - Quick probe (<=200KB): HTTP to cp.cloudflare.com:80 through tunnel. + No inner TLS. Proves tunnel works and measures latency. + - Speed test (>200KB): HTTPS to speed.cloudflare.com:443 with + inner TLS via ssl.MemoryBIO. Full throughput measurement. + + Returns (connect_ms, ttfb_ms, speed_mbps, error). + """ + import secrets as _sec + import uuid as _uuid_mod + writer = None + try: + t0 = time.monotonic() + + # -- 1. Outer connection (TLS or plain) -- + if security == "tls": + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + reader, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port, ssl=ctx, + server_hostname=sni), + timeout=6.0) + else: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port), + timeout=6.0) + + # -- 2. WebSocket upgrade -- + ws_key = base64.b64encode(_sec.token_bytes(16)).decode() + ws_req = ( + f"GET {ws_path} HTTP/1.1\r\n" + f"Host: {host}\r\n" + f"Upgrade: websocket\r\n" + f"Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + f"Sec-WebSocket-Version: 13\r\n\r\n" + ).encode() + writer.write(ws_req) + await writer.drain() + + # Read HTTP 101 response + hdr_buf = b"" + hdr_end = time.monotonic() + 6.0 + while b"\r\n\r\n" not in hdr_buf: + if time.monotonic() > hdr_end: + return -1, -1, 0, "ws-hdr-timeout" + chunk = await asyncio.wait_for(reader.read(4096), timeout=4.0) + if not chunk: + return -1, -1, 0, "ws-eof" + hdr_buf += chunk + if len(hdr_buf) > 16384: + return -1, -1, 0, "ws-hdr-overflow" + + sep = hdr_buf.index(b"\r\n\r\n") + 4 + hdr_txt = hdr_buf[:sep].decode("latin-1", errors="replace") + m = re.search(r'HTTP/\S+\s+(\d{3})', hdr_txt) + ws_status = m.group(1) if m else "" + if ws_status != "101": + return -1, -1, 0, f"ws-{ws_status or 'no-resp'}" + + extra = hdr_buf[sep:] + connect_ms = (time.monotonic() - t0) * 1000 + uuid_bytes = _uuid_mod.UUID(uuid_str).bytes + + # -- HTTP probe to cp.cloudflare.com:80 (no inner TLS) -- + # Always use cp.cloudflare.com -- speed.cloudflare.com is commonly + # blocked by VLESS server routing rules. + dest = b"cp.cloudflare.com" + http_req = ( + b"GET /cdn-cgi/trace HTTP/1.1\r\n" + b"Host: cp.cloudflare.com\r\n" + b"Connection: close\r\n\r\n" + ) + vless_payload = ( + b'\x00' + uuid_bytes + b'\x00' + + b'\x01' + (80).to_bytes(2, 'big') + + b'\x02' + bytes([len(dest)]) + dest + + http_req + ) + writer.write(_ws_frame_encode(vless_payload)) + await writer.drain() + + # Read VLESS response + HTTP data directly from WS frames + wsp = _WsFrameParser(extra) + vless_hdr_done = False + http_hdr_done = False + http_buf = b"" + body_total = 0 + ttfb_ms = -1.0 + dl_deadline = time.monotonic() + timeout + 3.0 + + while time.monotonic() < dl_deadline: + tun_data, vless_hdr_done, closed, _reason = \ + await _vless_ws_read_tunnel( + reader, wsp, vless_hdr_done, timeout=6.0) + if closed: + if not http_hdr_done: + return connect_ms, -1, 0, _reason or "probe-closed" + break + if not tun_data: + continue + + if not http_hdr_done: + http_buf += tun_data + if b"\r\n\r\n" in http_buf: + h_sep = http_buf.index(b"\r\n\r\n") + 4 + h_line = http_buf[:h_sep].decode( + "latin-1", errors="replace") + h_parts = h_line.split("\r\n")[0].split(None, 2) + h_code = h_parts[1] if len(h_parts) >= 2 else "" + if h_code not in ("200", "204"): + return connect_ms, -1, 0, f"probe-http:{h_code}" + ttfb_ms = ((time.monotonic() - t0) * 1000 + - connect_ms) + body_total = len(http_buf) - h_sep + http_hdr_done = True + else: + body_total += len(tun_data) + # /cdn-cgi/trace is small (~300B) -- done once we have it + if body_total > 50: + break + + if not http_hdr_done: + return connect_ms, -1, 0, "probe-no-response" + + dl_t = (time.monotonic() - t0) - (connect_ms / 1000) + mbps = (body_total / 1_000_000) / dl_t if dl_t > 0.001 else 0.01 + # Ensure non-zero speed so config is marked alive + return connect_ms, ttfb_ms, max(mbps, 0.001), "" + + except asyncio.TimeoutError: + return -1, -1, 0, "timeout" + except asyncio.CancelledError: + return -1, -1, 0, "cancelled" + except (OSError, ssl.SSLError) as e: + return -1, -1, 0, str(e)[:60] + except Exception as e: + return -1, -1, 0, f"{type(e).__name__}:{str(e)[:40]}" + finally: + if writer: + try: + writer.close() + except OSError: + pass + + +# ─── Variation Generation ───────────────────────────────────────────────── + + +def generate_xray_variations( + uri: str, + snis: Optional[List[str]], + frag_preset: str, + base_port: int, + clean_ips: Optional[List[str]] = None, +) -> List[XrayVariation]: + """Generate IP x SNI x fragment combinations from a single URI. + + When clean_ips is provided, each IP is tested with each SNI/fragment combo. + The original config IP is always tested first. + """ + parsed = None + if uri.strip().startswith("vless://"): + parsed = parse_vless_full(uri) + elif uri.strip().startswith("vmess://"): + parsed = parse_vmess_full(uri) + if not parsed: + return [] + + if not snis: + snis = [_infer_orig_sni(parsed) or parsed.get("address", "")] + + fragments = XRAY_FRAG_PRESETS.get(frag_preset, XRAY_FRAG_PRESETS["all"]) + orig_addr = parsed["address"] + + # Always prepend the original SNI/host so the base config is tested first + orig_sni = _infer_orig_sni(parsed) + # Ensure host is set so SNI rotation doesn't change the WS Host header + if not parsed.get("host") and orig_sni: + parsed["host"] = orig_sni + if orig_sni and parsed.get("security") not in ("none", "", "reality"): + snis = [s for s in snis if s != orig_sni] + snis.insert(0, orig_sni) + + # REALITY: SNI is cryptographically bound to public key, don't rotate + if parsed.get("security") == "reality": + snis = [parsed.get("sni") or (snis[0] if snis else "")] + + # No TLS / REALITY: SNI is meaningless or crypto-bound -- don't rotate + # Also nothing to fragment (tlshello fragmentation is meaningless or breaks REALITY) + # XTLS-Vision manages its own packet flow -- fragments break it + if parsed.get("security") in ("none", "", "reality"): + if parsed.get("security") != "reality": + snis = [_infer_orig_sni(parsed) or parsed.get("address", "")] + fragments = [None] + elif parsed.get("flow", "").startswith("xtls-rprx-vision"): + fragments = [None] + + # Build list of IPs to test: original first, then clean IPs + ips_to_test = [orig_addr] + if clean_ips: + for ip in clean_ips: + if ip != orig_addr and ip not in ips_to_test: + ips_to_test.append(ip) + + # When testing multiple IPs, limit SNIs/frags to keep total manageable + if len(ips_to_test) > 1: + # For multi-IP: top 8 SNIs x 2 frags x N IPs + snis = snis[:8] + if len(fragments) > 2: + fragments = [fragments[0], fragments[-1]] # none + heaviest + + # Guard: cap variations so base_port + idx stays in valid port range + max_variations = max(1, 65535 - base_port) + total_combos = len(ips_to_test) * len(snis) * len(fragments) + if total_combos > max_variations: + per_ip = max(1, max_variations // len(ips_to_test)) + snis = snis[:max(1, per_ip // max(1, len(fragments)))] + + variations: List[XrayVariation] = [] + idx = 0 + for ip in ips_to_test: + for sni in snis: + for fi, frag in enumerate(fragments): + if base_port + idx > 65535: + break + frag_label = "none" if frag is None else f"{frag.get('length', '?')}" + ip_short = ip if ip == orig_addr else ip + tag = f"{ip_short}|{sni}|{frag_label}" + config_json = build_xray_config( + parsed, sni, frag, base_port + idx, + address_override=ip, + ) + # Build result URI with the tested IP as address + _p = copy.copy(parsed) + _p["address"] = ip + result_uri = _build_uri(_p, sni, tag) + variations.append(XrayVariation( + tag=tag, + sni=sni, + fragment=frag, + config_json=config_json, + source_uri=uri, + result_uri=result_uri, + )) + idx += 1 + + return variations + + +def generate_pipeline_variations( + parsed: dict, + source_uri: str, + working_ips: List[str], + sni_pool: List[str], + frag_preset: str, + transport_variants: List[str], + base_port: int, + max_total: int = 200, + max_snis_per_ip: int = 10, + ip_ports: Optional[dict] = None, +) -> List[XrayVariation]: + """Generate xray variations for proven working IPs with budget control. + + Budget math distributes max_total across IPs x ports x transports x SNIs x frags. + ip_ports: optional {ip: [port1, port2, ...]} for multi-port variations. + Reuses build_xray_config(), switch_transport(), and URI builders. + """ + if not working_ips or not parsed: + return [] + + _sec = parsed.get("security") or "none" + _flow = parsed.get("flow") or "" + _no_tls = _sec in ("none", "") + _is_reality = _sec == "reality" + _is_vision = _flow.startswith("xtls-rprx-vision") + _orig_port = int(parsed.get("port", 443)) + + # Ensure host is set before SNI rotation -- if empty, rotating SNIs + # would change the WS/HTTP Host header (build_xray_config falls back + # to sni when host is empty). Set it to the original SNI so the + # Host stays constant regardless of which SNI is being tested. + orig_sni = _infer_orig_sni(parsed) + if not parsed.get("host") and orig_sni: + parsed = dict(parsed) # don't mutate caller's dict + parsed["host"] = orig_sni + + # Build SNI list -- use helper that handles CDN fronting correctly + # CF enforces zone matching: SNI must be in the same CF zone as the + # Host header. The host domain is therefore ALWAYS a valid SNI and + # should appear first. orig_sni (address domain) may be a *different* + # zone (domain-fronting configs), so it goes second. + if _is_reality: + effective_snis = [parsed.get("sni") or orig_sni or ""] + elif _no_tls: + effective_snis = [orig_sni or parsed.get("address", "")] + else: + effective_snis = [] + # Host domain first -- guaranteed same-zone as what CF routes by + _host = parsed.get("host", "") + if _host: + try: + ipaddress.ip_address(_host) + except (ValueError, TypeError): + # host is a domain (not IP) -> include it + effective_snis.append(_host) + # Original SNI second (may be different zone -- works for base config) + if orig_sni and orig_sni not in effective_snis: + effective_snis.append(orig_sni) + for s in sni_pool: + if s not in effective_snis: + effective_snis.append(s) + + # Build fragment list + if _no_tls or _is_reality or _is_vision: + fragments = [None] + else: + fragments = XRAY_FRAG_PRESETS.get(frag_preset, XRAY_FRAG_PRESETS["all"]) + + # Build transport list: original + variants + transport_configs = [("orig", parsed)] + if not _is_reality and not _no_tls: + for tv in transport_variants: + orig_type = parsed.get("type") or parsed.get("net") or "tcp" + if tv != orig_type: + switched = switch_transport(parsed, tv) + if switched: + transport_configs.append((tv, switched)) + + # xhttp mode variations: test different modes for xhttp/splithttp configs + _orig_net = parsed.get("type") or parsed.get("net") or "tcp" + _xhttp_modes: List[str] = [] + if _orig_net in ("xhttp", "splithttp"): + _orig_mode = parsed.get("mode", "auto") or "auto" + for _m in ["auto", "packet-up", "stream-up", "stream-down"]: + if _m != _orig_mode: + _xhttp_modes.append(_m) + + # Budget: distribute max_total across IPs x ports x SNIs x frags x transports + # With empty sni_pool, effective_snis has just host + orig_sni (1-2 entries). + # Budget goes mostly to IPs x fragments. + n_ips = len(working_ips) + n_transports = len(transport_configs) + n_frags = len(fragments) + # Count total port variants per IP + _avg_ports = 1 + if ip_ports: + _total_ports = sum(len(ip_ports.get(ip, [_orig_port])) for ip in working_ips) + _avg_ports = max(1, _total_ports // n_ips) + per_ip = max(1, max_total // n_ips) + per_port = max(1, per_ip // _avg_ports) + # SNIs get the full per-port budget -- fragments divide what's left per SNI + snis_budget = max(1, min(max_snis_per_ip, per_port, + len(effective_snis))) + n_frags_eff = max(1, per_port // max(1, snis_budget)) + fragments = fragments[:n_frags_eff] + # Cap transports to fit remaining budget + _t_budget = max(1, per_port // max(1, snis_budget * n_frags_eff)) + transport_configs = transport_configs[:_t_budget] + effective_snis = effective_snis[:snis_budget] + + _dbg(f"[gen_vars] n_ips={n_ips} max_total={max_total} per_ip={per_ip} " + f"per_port={per_port} snis_budget={snis_budget} n_frags={n_frags_eff} " + f"transports={len(transport_configs)} effective_snis={len(effective_snis)} " + f"expected={n_ips * snis_budget * n_frags_eff * len(transport_configs)}") + + variations: List[XrayVariation] = [] + idx = 0 + + def _add_variation(ip: str, srv_port: int, t_name: str, t_parsed: dict, + sni: str, frag, mode_label: str = "") -> bool: + """Add one variation. Returns False if budget exhausted.""" + nonlocal idx + if base_port + idx > 65535 or len(variations) >= max_total: + return False + t_type = t_parsed.get("type") or t_parsed.get("net") or "tcp" + frag_label = "none" if frag is None else f"{frag.get('length', '?')}" + t_label = t_type if t_name != "orig" else "" + port_label = f":{srv_port}" if srv_port != 443 else "" + tag = f"{ip}{port_label}|{sni}|{frag_label}" + if t_label: + tag += f"|{t_label}" + if mode_label: + tag += f"|{mode_label}" + + _p = copy.copy(t_parsed) + _p["address"] = ip + _p["port"] = srv_port + + config_json = build_xray_config( + _p, sni, frag, base_port + idx, + address_override=ip, + ) + result_uri = _build_uri(_p, sni, tag) + + variations.append(XrayVariation( + tag=tag, sni=sni, fragment=frag, + config_json=config_json, + source_uri=source_uri, + result_uri=result_uri, + )) + idx += 1 + return True + + for ip in working_ips: + ports = ip_ports.get(ip, [_orig_port]) if ip_ports else [_orig_port] + for srv_port in ports: + for t_name, t_parsed in transport_configs: + for sni in effective_snis: + for frag in fragments: + if not _add_variation(ip, srv_port, t_name, t_parsed, + sni, frag): + break + # xhttp mode variations: test other modes on first frag only + if _xhttp_modes and frag is None: + t_type = t_parsed.get("type") or t_parsed.get("net") or "tcp" + if t_type in ("xhttp", "splithttp"): + for _m in _xhttp_modes: + _mp = copy.copy(t_parsed) + _mp["mode"] = _m + if not _add_variation(ip, srv_port, t_name, _mp, + sni, frag, _m): + break + if len(variations) >= max_total: + break + if len(variations) >= max_total: + break + if len(variations) >= max_total: + break + if len(variations) >= max_total: + break + + return variations + + +def expand_custom_ips(raw_input: str) -> List[str]: + """Expand user input (IPs, CIDRs, or file path) into a list of individual IPs. + + Accepts: + - Single IPs: "1.2.3.4" + - CIDR notation: "104.16.0.0/24" + - Comma-separated mix: "1.2.3.4, 10.0.0.0/30" + - File path (one IP/CIDR per line) + Returns deduplicated list of IPs (max 6666 to avoid memory issues). + """ + MAX_IPS = 6666 + entries: List[str] = [] + + # Check if input is a file path + raw = raw_input.strip() + if os.path.isfile(raw): + try: + with open(raw, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + entries.append(line) + except OSError: + pass + else: + # Comma or newline separated + for part in raw.replace("\n", ",").split(","): + part = part.strip() + if part: + entries.append(part) + + seen: set = set() + result: List[str] = [] + for entry in entries: + try: + # Try as single IP first + ip = ipaddress.IPv4Address(entry) + if str(ip) not in seen: + seen.add(str(ip)) + result.append(str(ip)) + except ValueError: + try: + # Try as CIDR + net = ipaddress.IPv4Network(entry, strict=False) + for host in net.hosts(): + if len(result) >= MAX_IPS: + break + s = str(host) + if s not in seen: + seen.add(s) + result.append(s) + except ValueError: + continue + if len(result) >= MAX_IPS: + break + return result + + +# ─── Xray Testing & Pipeline ───────────────────────────────────────────── + + +def _xray_calc_scores(xst: XrayTestState): + """Calculate scores for xray variations.""" + for v in xst.variations: + if not v.alive: + v.score = 0 + continue + cms = v.connect_ms if v.connect_ms >= 0 else 1000 + tms = v.ttfb_ms if v.ttfb_ms >= 0 else 1000 + lat = max(0.0, 100.0 - cms / 10.0) + ttfb = max(0.0, 100.0 - tms / 5.0) + if v.native_tested or v.speed_mbps < 0.01: + # Native VLESS test: no real speed data, score on latency only + v.score = round(lat * 0.55 + ttfb * 0.45, 1) + else: + spd = min(100.0, v.speed_mbps * 20.0) + v.score = round(lat * 0.35 + spd * 0.50 + ttfb * 0.15, 1) + + +async def _test_single_variation( + var: XrayVariation, xray_bin: str, size: int, timeout: float, +) -> bool: + """Test one XrayVariation via Python-native VLESS or xray SOCKS5. + + For VLESS+WS configs without fragments, uses a direct Python tunnel + (TLS->WS->VLESS->HTTP) which avoids xray binary issues. + Falls back to xray SOCKS5 proxy for all other protocols. + + Mutates var in place (alive, connect_ms, ttfb_ms, speed_mbps, error). + Returns True if alive (mbps > 0). + """ + # -- Try Python-native VLESS test for ALL VLESS+WS configs -- + # Even for fragment variations: if the SNI/IP doesn't work without + # fragments (e.g. CF returns 403), it won't work with fragments either + # (fragments only affect DPI, not CF routing). This avoids falling + # through to the xray binary which has SSL issues. + params = _extract_vless_ws_params(var.config_json) + if params and params["uuid"]: + connect_ms, ttfb_ms, mbps, err = await _vless_ws_speed_test( + ip=params["ip"], port=params["port"], + sni=params["sni"] or params["host"], + host=params["host"] or params["sni"], + ws_path=params["path"], + uuid_str=params["uuid"], + size=size, timeout=timeout, + security=params["security"], + ) + var.connect_ms = connect_ms + if mbps > 0: + var.alive = True + var.native_tested = True + var.ttfb_ms = ttfb_ms + var.speed_mbps = mbps + return True + else: + # For fragment variations: native test proves connectivity. + # If it fails, no point trying xray binary (same CF routing). + var.error = err or "no-data" + return False + + # -- Fallback: xray SOCKS5 proxy test -- + loop = asyncio.get_running_loop() + os.makedirs(XRAY_TMP_DIR, exist_ok=True) + + ports = _find_free_ports(XRAY_BASE_PORT, 1) + if not ports: + var.error = "no-free-port" + return False + port = ports[0] + + test_cfg = copy.deepcopy(var.config_json) + test_cfg["inbounds"][0]["port"] = port + + config_path = os.path.join(XRAY_TMP_DIR, f"xray_{port}.json") + try: + _fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(_fd, "w", encoding="utf-8") as f: + json.dump(test_cfg, f) + except OSError as e: + var.error = f"write-cfg:{str(e)[:30]}" + try: + os.remove(config_path) + except OSError: + pass + return False + + xp = XrayProcess(xray_bin, config_path, port) + try: + if not await loop.run_in_executor(None, xp.start): + var.error = xp.last_error[:40] if xp.last_error else "xray-start-fail" + return False + + connect_ms, ttfb_ms, mbps, err = await xray_speed_test( + port, size, timeout, + ) + var.connect_ms = connect_ms + if mbps > 0: + var.alive = True + var.ttfb_ms = ttfb_ms + var.speed_mbps = mbps + return True + else: + _py_err = err or "no-data" + # Stop xray FIRST so it flushes all output to .err file + xp.stop() + xp._read_stderr_file() + if xp.last_error: + var.error = xp.last_error[:60] + else: + var.error = _py_err + return False + finally: + xp.stop() # safe to call twice + xp.cleanup() + + +async def xray_pipeline_test(xst: XrayTestState, pcfg: PipelineConfig): + """Progressive 3-stage pipeline for xray proxy testing. + + Stage 1: IP Scan -- TLS probe CF_TEST_IPS + original IP (~10s) + Stage 2: Base Test -- Real xray test with original config on live IPs (~30-60s) + Stage 3: Expansion -- SNI + fragment + transport variations on working IPs (~2-3 min) + """ + xst.pipeline_mode = True + xst.start_time = time.monotonic() + os.makedirs(XRAY_TMP_DIR, exist_ok=True) + # Clean stale temp configs + for stale in globmod.glob(os.path.join(XRAY_TMP_DIR, "xray_*.json")): + try: + os.remove(stale) + except OSError: + pass + + orig_addr = pcfg.parsed.get("address", "") + orig_sni = _infer_orig_sni(pcfg.parsed) or "speed.cloudflare.com" + orig_port = int(pcfg.parsed.get("port", 443)) + _sec = pcfg.parsed.get("security") or "none" + _is_reality = _sec == "reality" + _no_tls = _sec in ("none", "") + _is_cf = _is_cf_address(orig_addr) + if not _is_cf and not _is_reality and not _no_tls: + _is_cf = _resolve_is_cf(orig_addr) + + # Auto-detect: find alternative SNI to try if primary fails + # With new _infer_orig_sni, orig_sni is the address domain for CDN + # fronting configs; the host domain becomes the fallback (and vice versa). + _alt_sni = "" + if not _is_reality and not _no_tls: + _host = pcfg.parsed.get("host", "") + _av = pcfg.parsed.get("address", "") + _addr_is_domain = False + try: + ipaddress.ip_address(_av) + except (ValueError, TypeError): + _addr_is_domain = bool(_av) + # Pick an alternative that differs from orig_sni + if _host and _host != orig_sni: + _alt_sni = _host + elif _addr_is_domain and _av != orig_sni: + _alt_sni = _av + + # -- Pre-flight: verify server and auto-detect SNI mode -- + if not _is_reality and not _no_tls and orig_addr: + _pf_addr = orig_addr + try: + ipaddress.ip_address(orig_addr) + except (ValueError, TypeError): + if _CF_PREFLIGHT_IPS: + _pf_addr = _CF_PREFLIGHT_IPS[0] + + xst.phase_label = f"Pre-flight: checking {orig_sni}:{orig_port}..." + pf_lat, pf_is_cf, pf_err = await _tls_probe( + _pf_addr, orig_sni, timeout=5.0, validate=True, port=orig_port) + xst.preflight_is_cf = pf_is_cf if pf_lat > 0 else None + + # Only switch SNI if the TLS connection itself completely failed. + if _alt_sni and pf_lat <= 0: + xst.phase_label = f"Pre-flight: trying {_alt_sni}..." + pf2_lat, pf2_is_cf, pf2_err = await _tls_probe( + _pf_addr, _alt_sni, timeout=5.0, validate=True, + port=orig_port) + if pf2_lat > 0 and pf2_is_cf and not pf2_err.startswith( + "cf-origin-"): + # Alternative SNI works -- switch + orig_sni = _alt_sni + _alt_sni = "" + xst.preflight_is_cf = True + pf_lat, pf_is_cf, pf_err = pf2_lat, pf2_is_cf, pf2_err + + # Set warnings based on final pre-flight result + if pf_lat <= 0: + xst.preflight_warning = ( + f"Server {orig_sni}:{orig_port} unreachable") + elif not pf_is_cf: + xst.preflight_warning = ( + f"TLS OK but HTTP validation inconclusive for {orig_sni} " + f"(origin may only accept WebSocket)") + elif pf_err.startswith("cf-origin-"): + _pf_code = pf_err.split('-')[-1] + if _pf_code == "403": + _pf_hint = "domain may not be on Cloudflare" + elif _pf_code in ("521", "522", "523"): + _pf_hint = "origin server is down or unreachable" + elif _pf_code in ("502", "520", "530"): + _pf_hint = "origin DNS or routing error" + else: + _pf_hint = "server may be down or misconfigured" + xst.preflight_warning = ( + f"CF edge OK but HTTP {_pf_code} -- {_pf_hint}") + + # -- VLESS tunnel probe: test WS upgrade + VLESS handshake -- + _ws_net = pcfg.parsed.get("type") or pcfg.parsed.get("net") or "tcp" + _ws_host = pcfg.parsed.get("host") or orig_sni + _ws_path = pcfg.parsed.get("path") or "/" + _uuid_str = pcfg.parsed.get("uuid", "") + if pf_lat > 0 and _ws_net in ("ws", "websocket") and _uuid_str: + xst.phase_label = f"Pre-flight: testing VLESS tunnel..." + _vless_diag = "" + try: + _ws_ip = _pf_addr + _ws_ctx = ssl.create_default_context() + _ws_ctx.check_hostname = False + _ws_ctx.verify_mode = ssl.CERT_NONE + _ws_r, _ws_w = await asyncio.wait_for( + asyncio.open_connection( + _ws_ip, orig_port, ssl=_ws_ctx, + server_hostname=orig_sni), + timeout=5.0) + # Step 1: WebSocket upgrade + import secrets as _secrets + _ws_key = base64.b64encode(_secrets.token_bytes(16)).decode() + _ws_req = ( + f"GET {_ws_path} HTTP/1.1\r\n" + f"Host: {_ws_host}\r\n" + f"Upgrade: websocket\r\n" + f"Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {_ws_key}\r\n" + f"Sec-WebSocket-Version: 13\r\n\r\n" + ) + _ws_w.write(_ws_req.encode()) + await _ws_w.drain() + + # Read HTTP response -- must find \r\n\r\n boundary + _hdr_buf = b"" + _hdr_deadline = time.monotonic() + 5.0 + while b"\r\n\r\n" not in _hdr_buf: + if time.monotonic() > _hdr_deadline: + break + _chunk = await asyncio.wait_for( + _ws_r.read(4096), timeout=3.0) + if not _chunk: + break + _hdr_buf += _chunk + if len(_hdr_buf) > 8192: + break + + _ws_txt = _hdr_buf.decode("latin-1", errors="replace") + _ws_status = "" + _ws_m = re.search(r'HTTP/\S+\s+(\d{3})', _ws_txt) + if _ws_m: + _ws_status = _ws_m.group(1) + + # Extract any WS data after the HTTP headers + _ws_extra = b"" + if b"\r\n\r\n" in _hdr_buf: + _ws_extra = _hdr_buf[_hdr_buf.index(b"\r\n\r\n") + 4:] + + if _ws_status != "101": + _ws_first_line = _ws_txt.split("\r\n", 1)[0] + _vless_diag = f"WS {_ws_status or 'no-resp'}: {_ws_first_line[:40]}" + else: + # Step 2: Build VLESS header + HTTP request payload + import uuid as _uuid_mod + _uuid_bytes = _uuid_mod.UUID(_uuid_str).bytes + _dest = b"cp.cloudflare.com" + _http_req = ( + b"GET /cdn-cgi/trace HTTP/1.1\r\n" + b"Host: cp.cloudflare.com\r\n" + b"Connection: close\r\n\r\n" + ) + _vless_payload = ( + b'\x00' # version + + _uuid_bytes # UUID (16 bytes) + + b'\x00' # addon length + + b'\x01' # command: TCP + + (80).to_bytes(2, 'big') # port 80 (HTTP) + + b'\x02' # addr type: domain + + bytes([len(_dest)]) # domain length + + _dest # domain + + _http_req # first data chunk + ) + # Wrap in WS binary frame (client must mask) + _mask = _secrets.token_bytes(4) + _masked = bytes( + b ^ _mask[i % 4] for i, b in enumerate(_vless_payload)) + _frame_len = len(_vless_payload) + if _frame_len <= 125: + _ws_frame = (bytes([0x82, 0x80 | _frame_len]) + + _mask + _masked) + else: + _ws_frame = (bytes([0x82, 0xFE]) + + _frame_len.to_bytes(2, 'big') + + _mask + _masked) + _ws_w.write(_ws_frame) + await _ws_w.drain() + + # Step 3: Read VLESS response + HTTP data (over WS) + try: + _vr = _ws_extra # include any data from HTTP read + _vr += await asyncio.wait_for( + _ws_r.read(2048), timeout=8.0) + if len(_vr) >= 4: + # Parse WS frame + _op = _vr[0] & 0x0F + _plen = _vr[1] & 0x7F + _pstart = 2 + if _plen == 126: + _plen = int.from_bytes(_vr[2:4], 'big') + _pstart = 4 + elif _plen == 127: + _pstart = 10 + _payload = _vr[_pstart:_pstart + _plen] + + if _op == 8: + # WS close frame + _close_code = int.from_bytes( + _payload[:2], 'big') if len(_payload) >= 2 else 0 + _close_reason = _payload[2:].decode( + 'utf-8', errors='replace') if len(_payload) > 2 else "" + _vless_diag = ( + f"VLESS rejected: WS close {_close_code}" + f"{' ' + _close_reason[:30] if _close_reason else ''}" + f" (UUID may be wrong/expired)") + elif len(_payload) >= 2 and _payload[0] == 0x00: + # VLESS v0 response header (2+ bytes) + _addon_len = _payload[1] + _data_after = _payload[2 + _addon_len:] + # Check if HTTP response follows + if b"HTTP" in _data_after[:20]: + _vless_diag = "VLESS tunnel OK -- proxy works!" + else: + _vless_diag = ( + f"VLESS tunnel OK (v0) " + f"data={_data_after[:16].hex()}") + else: + _vless_diag = ( + f"VLESS unexpected: op={_op} len={_plen} " + f"hex={_payload[:12].hex() if _payload else 'empty'}") + elif len(_vr) > 0: + _vless_diag = ( + f"VLESS short: {len(_vr)}B " + f"{_vr[:20].hex()}") + else: + _vless_diag = "VLESS: empty response" + except asyncio.TimeoutError: + _vless_diag = "VLESS: timeout (origin can't reach destination?)" + + _ws_w.close() + try: + await _ws_w.wait_closed() + except OSError: + pass + except asyncio.TimeoutError: + _vless_diag = "TLS/WS timeout" + except OSError as _ws_e: + _vless_diag = f"connect: {str(_ws_e)[:40]}" + + if _vless_diag: + xst.preflight_warning = ( + (xst.preflight_warning + " | " if xst.preflight_warning else "") + + _vless_diag) + + # -- Stage 1: IP Scan -- + xst.pipeline_stage = 0 + xst.pipeline_stages[0]["status"] = "active" + xst.phase = "ip_scan" + + if _is_reality: + # REALITY: only probe the original server IP on its port/SNI + xst.phase_label = f"Probing {orig_addr}:{orig_port}..." + probe_ips = [orig_addr] if orig_addr else [] + probe_sni = orig_sni + probe_ports = [orig_port] + else: + # Cloudflare-fronted: probe CF IPs on configured ports + probe_ips = list(pcfg.custom_ips) if pcfg.custom_ips else list(CF_TEST_IPS) + if orig_addr and orig_addr not in probe_ips: + probe_ips.insert(0, orig_addr) + probe_sni = "speed.cloudflare.com" + probe_ports = pcfg.probe_ports if pcfg.probe_ports else [orig_port] + n_ports = len(probe_ports) + port_label = f" x {n_ports} ports ({','.join(str(p) for p in probe_ports)})" if n_ports > 1 else f" on port {probe_ports[0]}" + if pcfg.custom_ips: + _cf_range_count = sum(1 for ip in probe_ips if _is_cf_address(ip)) + _non_cf = len(probe_ips) - _cf_range_count + if _non_cf > len(probe_ips) * 0.5: + xst.preflight_warning = ( + (xst.preflight_warning + " | " if xst.preflight_warning else "") + + f"{_non_cf}/{len(probe_ips)} custom IPs outside known CF ranges") + xst.phase_label = ( + f"Scanning {len(probe_ips)} IPs " + f"({_cf_range_count} in CF ranges){port_label}...") + else: + xst.phase_label = f"Scanning {len(probe_ips)} IPs{port_label}..." + + # Build (ip, port) probe pairs + probe_pairs: List[Tuple[str, int]] = [] + for ip in probe_ips: + for port in probe_ports: + probe_pairs.append((ip, port)) + + # Scale concurrency: 50 for default CF_TEST_IPS, up to 200 for large custom sets + _sem_count = min(200, max(50, len(probe_pairs) // 20)) + sem = asyncio.Semaphore(_sem_count) + xst.total = len(probe_pairs) + xst.done_count = 0 + + async def _probe_one(ip: str, port: int) -> Optional[Tuple[str, int, float]]: + async with sem: + if xst.interrupted: + return None + try: + lat, is_cf, err = await _tls_probe(ip, probe_sni, timeout=4.0, + validate=True, port=port) + xst.done_count += 1 + if lat > 0 and is_cf: + if err.startswith("cf-origin-"): + xst.cf_origin_errors += 1 + return (ip, port, lat) + except Exception: + xst.done_count += 1 + return None + + results = await asyncio.gather(*[_probe_one(ip, port) for ip, port in probe_pairs]) + # Deduplicate IPs -- keep best latency per IP; track all working ports + _ip_best: dict = {} # ip -> best latency + xst.live_ip_ports = {} + for r in results: + if r is not None: + ip, port, lat = r + if ip not in _ip_best or lat < _ip_best[ip]: + _ip_best[ip] = lat + if ip not in xst.live_ip_ports: + xst.live_ip_ports[ip] = [] + if port not in xst.live_ip_ports[ip]: + xst.live_ip_ports[ip].append(port) + xst.live_ips = sorted([(ip, lat) for ip, lat in _ip_best.items()], key=lambda x: x[1]) + + xst.pipeline_stages[0]["status"] = "done" + _cf_count = len(xst.live_ips) + _origin_warn = f" ({xst.cf_origin_errors} with origin errors)" if xst.cf_origin_errors else "" + _port_info = f" on port {probe_ports[0]}" if len(probe_ports) == 1 else f" on ports {','.join(str(p) for p in probe_ports)}" + xst.phase_label = f"IP Scan: {_cf_count} CF confirmed{_port_info}{_origin_warn}" + + if not xst.live_ips or xst.interrupted: + xst.pipeline_stages[0]["status"] = "interrupted" + xst.finished = True + if _is_cf: + if xst.preflight_warning: + xst.phase_label = "No Cloudflare IPs found -- server may not be behind CF CDN" + else: + xst.phase_label = "No Cloudflare IPs found -- check your network or IP list" + else: + xst.phase_label = f"Server {orig_addr}:{orig_port} unreachable -- check address/port" + _xray_calc_scores(xst) + return + + # -- Stage 2: Base Connectivity -- + xst.pipeline_stage = 1 + xst.pipeline_stages[1]["status"] = "active" + xst.phase = "base_test" + + # For config-less mode: test each base URI on original IP first + if pcfg.configless and pcfg.base_uris: + xst.phase_label = f"Testing {len(pcfg.base_uris)} base configs..." + xst.total = len(pcfg.base_uris) + xst.done_count = 0 + + working_uri = None + working_parsed = None + for uri, parsed in pcfg.base_uris: + if xst.interrupted: + break + _sni = _infer_orig_sni(parsed) or "speed.cloudflare.com" + cfg_json = build_xray_config(parsed, _sni, None, XRAY_BASE_PORT, + address_override=orig_addr) + var = XrayVariation( + tag=f"{orig_addr}|{_sni}|none", + sni=_sni, fragment=None, + config_json=cfg_json, + source_uri=uri, result_uri=uri, + ) + alive = await _test_single_variation(var, xst.xray_bin, + XRAY_QUICK_SIZE, XRAY_QUICK_TIMEOUT) + xst.done_count += 1 + if alive: + working_uri = uri + working_parsed = parsed + xst.working_ips.append(orig_addr) + xst.variations.append(var) + xst.alive_count += 1 + break + else: + xst.dead_count += 1 + + if not working_uri: + xst.pipeline_stages[1]["status"] = "done" + xst.finished = True + xst.phase_label = "No base config could connect -- server may not support ws/xhttp" + _xray_calc_scores(xst) + return + + # Use the working config for remaining stages + pcfg.uri = working_uri + pcfg.parsed = working_parsed + orig_sni = _infer_orig_sni(working_parsed) + + # Test live IPs with the base config + test_ips = [ip for ip, _ in xst.live_ips[:pcfg.max_stage2_ips]] + # Ensure original IP is always tested + if orig_addr and orig_addr not in test_ips: + test_ips.insert(0, orig_addr) + + xst.total = len(test_ips) + xst.done_count = 0 + xst.phase_label = f"Base test: 0/{len(test_ips)} IPs..." + + # Build all base variations upfront + _base_vars: List[Tuple[str, XrayVariation]] = [] + for ip in test_ips: + _test_port = (xst.live_ip_ports.get(ip, []) or [orig_port])[0] + _p = copy.copy(pcfg.parsed) + _p["address"] = ip + _p["port"] = _test_port + cfg_json = build_xray_config(_p, orig_sni, None, + XRAY_BASE_PORT + len(_base_vars), + address_override=ip) + r_uri = _build_uri(_p, orig_sni, f"{ip}|{orig_sni}|none") + var = XrayVariation( + tag=f"{ip}|{orig_sni}|none", + sni=orig_sni, fragment=None, + config_json=cfg_json, + source_uri=pcfg.uri, result_uri=r_uri, + ) + _base_vars.append((ip, var)) + + # Run Stage 2 in parallel batches + _base_sem = asyncio.Semaphore(10) + + async def _test_base(ip: str, var: XrayVariation) -> None: + async with _base_sem: + if xst.interrupted: + return + alive = await _test_single_variation(var, xst.xray_bin, + XRAY_QUICK_SIZE, XRAY_QUICK_TIMEOUT) + xst.done_count += 1 + xst.variations.append(var) + if alive: + if ip not in xst.working_ips: + xst.working_ips.append(ip) + xst.alive_count += 1 + else: + xst.dead_count += 1 + xst.phase_label = ( + f"Base test: {xst.done_count}/{len(test_ips)} " + f"({len(xst.working_ips)} working)") + + for _ci in range(0, len(_base_vars), 20): + if xst.interrupted: + break + batch = _base_vars[_ci:_ci + 20] + await asyncio.gather(*[_test_base(ip, var) for ip, var in batch]) + + # Fallback: if no IPs work, try alternative SNIs on original IP + # Skip for REALITY (SNI is crypto-bound) and no-TLS (SNI meaningless) + if not xst.working_ips and not xst.interrupted and not _is_reality and not _no_tls: + _fb_base = [_alt_sni] if _alt_sni else [] + _fb_common = ["speed.cloudflare.com", "dash.cloudflare.com", "chatgpt.com"] + fallback_snis = _fb_base + [s for s in _fb_common if s not in _fb_base] + fallback_snis = [s for s in fallback_snis if s and s != orig_sni] + xst.phase_label = "Trying fallback SNIs..." + for fb_sni in fallback_snis: + if xst.interrupted: + break + cfg_json = build_xray_config(pcfg.parsed, fb_sni, None, XRAY_BASE_PORT, + address_override=orig_addr) + _fb_p = copy.copy(pcfg.parsed) + _fb_p["address"] = orig_addr + fb_result_uri = _build_uri(_fb_p, fb_sni, f"{orig_addr}|{fb_sni}|none") + var = XrayVariation( + tag=f"{orig_addr}|{fb_sni}|none", + sni=fb_sni, fragment=None, + config_json=cfg_json, + source_uri=pcfg.uri, result_uri=fb_result_uri, + ) + alive = await _test_single_variation(var, xst.xray_bin, + XRAY_QUICK_SIZE, XRAY_QUICK_TIMEOUT) + if alive: + orig_sni = fb_sni # Update SNI for Stage 3 + xst.working_ips.append(orig_addr) + xst.variations.append(var) + xst.alive_count += 1 + break + + xst.pipeline_stages[1]["status"] = "interrupted" if xst.interrupted else "done" + + # If base config failed but we have live CF IPs, don't give up -- + # proceed to expansion with different SNIs/fragments/transports. + _base_failed = not xst.working_ips + _fallback_ips: List[str] = [] + if _base_failed and not xst.interrupted and _is_cf and xst.live_ips: + _fallback_ips = [ip for ip, _ in xst.live_ips[:min(20, pcfg.max_stage2_ips)]] + xst.phase_label = ( + f"Base config failed -- expanding with fragments " + f"on {len(_fallback_ips)} IPs...") + elif not xst.working_ips or xst.interrupted: + xst.finished = True + if not _is_cf: + xst.phase_label = ( + f"Connection failed -- server {orig_addr}:{orig_port} " + f"not responding to xray") + elif xst.cf_origin_errors > 0: + xst.phase_label = ( + "CF edge IPs found but origin is unreachable -- " + "check server config (UUID, path, protocol)") + else: + xst.phase_label = ( + "No working IP found -- config may be invalid or " + "server not properly behind Cloudflare") + _xray_calc_scores(xst) + return + + # -- Stage 3: Expansion -- + xst.pipeline_stage = 2 + xst.pipeline_stages[2]["status"] = "active" + xst.phase = "expansion" + + # When base config failed, use live CF IPs for expansion instead + _expansion_ips = xst.working_ips if xst.working_ips else ( + _fallback_ips if _base_failed else []) + + # Ensure the proven working SNI is first in the pool for Stage 3 + if orig_sni: + if orig_sni in pcfg.sni_pool: + pcfg.sni_pool.remove(orig_sni) + pcfg.sni_pool.insert(0, orig_sni) + + _dbg(f"[expansion] IPs={len(_expansion_ips)} sni_pool={len(pcfg.sni_pool)} " + f"frag={pcfg.frag_preset} transports={pcfg.transport_variants} " + f"max_exp={pcfg.max_expansion} max_snis={pcfg.max_snis_per_ip} " + f"sni_sample={pcfg.sni_pool[:5]}") + + expansion_vars = generate_pipeline_variations( + pcfg.parsed, pcfg.uri, _expansion_ips, pcfg.sni_pool, + pcfg.frag_preset, pcfg.transport_variants, + XRAY_BASE_PORT, pcfg.max_expansion, pcfg.max_snis_per_ip, + ip_ports=xst.live_ip_ports if xst.live_ip_ports else None, + ) + + _dbg(f"[expansion] generated={len(expansion_vars)} " + f"unique_snis={len(set(v.sni for v in expansion_vars))}") + + # Remove duplicates (variations already tested in Stage 2) + tested_tags = {v.tag for v in xst.variations} + expansion_vars = [v for v in expansion_vars if v.tag not in tested_tags] + _dbg(f"[expansion] after dedup={len(expansion_vars)}") + + _exp_frag_count = len(set(str(v.fragment) for v in expansion_vars)) + xst.total = len(expansion_vars) + xst.done_count = 0 + xst.phase_label = (f"Expansion: 0/{len(expansion_vars)} " + f"({len(_expansion_ips)} IPs, {_exp_frag_count} frags)...") + + # Run expansion tests in parallel batches for speed + _exp_sem = asyncio.Semaphore(20) + + async def _test_exp(var: XrayVariation) -> None: + async with _exp_sem: + if xst.interrupted: + return + alive = await _test_single_variation(var, xst.xray_bin, + XRAY_QUICK_SIZE, XRAY_QUICK_TIMEOUT) + xst.done_count += 1 + if alive: + xst.alive_count += 1 + else: + xst.dead_count += 1 + xst.phase_label = ( + f"Expansion: {xst.done_count}/{len(expansion_vars)} " + f"({xst.alive_count} alive)") + + # Process in chunks to allow interrupt checks and append results in order + _chunk = 60 + for _ci in range(0, len(expansion_vars), _chunk): + if xst.interrupted: + break + batch = expansion_vars[_ci:_ci + _chunk] + await asyncio.gather(*[_test_exp(v) for v in batch]) + xst.variations.extend(batch) + + xst.pipeline_stages[2]["status"] = "interrupted" if xst.interrupted else "done" + xst.quick_passed = xst.alive_count + + xst.finished = True + _xray_calc_scores(xst) + + def load_input(path: str) -> List[ConfigEntry]: try: with open(path, "r", encoding="utf-8") as f: @@ -839,6 +3246,7 @@ async def _tls_probe( """TLS probe with optional Cloudflare header validation. Returns (latency_ms, is_cloudflare, error).""" w = None + cf_err = "" try: ctx = ssl.create_default_context() ctx.check_hostname = False @@ -851,34 +3259,44 @@ async def _tls_probe( tls_ms = (time.monotonic() - t0) * 1000 is_cf = True + htxt = "" if validate: is_cf = False try: - req = f"GET / HTTP/1.1\r\nHost: {sni}\r\nConnection: close\r\n\r\n" + safe_sni = sni.replace("\r", "").replace("\n", "") + req = f"GET / HTTP/1.1\r\nHost: {safe_sni}\r\nConnection: close\r\n\r\n" w.write(req.encode()) await w.drain() hdr = await asyncio.wait_for(r.read(2048), timeout=min(timeout, 3)) htxt = hdr.decode("latin-1", errors="replace").lower() is_cf = "server: cloudflare" in htxt or "cf-ray:" in htxt - except Exception: + except OSError: pass + if is_cf: + _status_line = htxt.split("\r\n", 1)[0] if "\r\n" in htxt else "" + _sm = re.search(r'http/\S+\s+(\d{3})', _status_line) + if _sm: + _scode = int(_sm.group(1)) + if _scode >= 400: + cf_err = f"cf-origin-{_scode}" + w.close() try: await w.wait_closed() - except Exception: + except OSError: pass w = None - return tls_ms, is_cf, "" + return tls_ms, is_cf, cf_err except asyncio.TimeoutError: return -1, False, "timeout" - except Exception as e: + except OSError as e: return -1, False, str(e)[:40] finally: if w: try: w.close() - except Exception: + except OSError: pass @@ -1512,82 +3930,479 @@ def draw_box_bottom(cols: int) -> str: return f"{A.CYN}╚{'═' * (cols - 2)}╝{A.RST}" -def tui_show_guide(): - """Show help/guide screen explaining input formats.""" - _w(A.CLR + A.HOME) - cols, rows = term_size() - lines = draw_menu_header(cols) - lines.append(draw_box_line(f" {A.BOLD}{A.WHT}How to prepare input files{A.RST}", cols)) - lines.append(draw_box_sep(cols)) +def _help_show_page(title: str, content: List[str]): + """Render a scrollable help sub-page. j/k/arrows scroll, b goes back.""" + scroll = 0 + while True: + _w(A.CLR + A.HOME + A.HIDE) + cols, rows = term_size() + W = cols - 2 + visible = max(3, rows - 8) + page = content[scroll:scroll + visible] + max_scroll = max(0, len(content) - visible) - guide = [ + out: List[str] = [] + _cpos = f"\033[{W + 2}G" + out.append(f"{A.CYN}{'=' * (W + 2)}{A.RST}") + t = f" {A.BOLD}{A.WHT}cfray{A.RST} {A.DIM}v{VERSION}{A.RST}" + out.append(f"{A.CYN}|{A.RST}{t}{' ' * max(0, W - _vl(t))}{_cpos}{A.CYN}|{A.RST}") + out.append(f"{A.CYN}{'-' * (W + 2)}{A.RST}") + ttl = f" {A.BOLD}{A.WHT}{title}{A.RST}" + out.append(f"{A.CYN}|{A.RST}{ttl}{' ' * max(0, W - _vl(ttl))}{_cpos}{A.CYN}|{A.RST}") + out.append(f"{A.CYN}{'-' * (W + 2)}{A.RST}") + for line in page: + vl = _vl(line) + out.append(f"{A.CYN}|{A.RST}{line}{' ' * max(0, W - vl)}{_cpos}{A.CYN}|{A.RST}") + for _ in range(visible - len(page)): + out.append(f"{A.CYN}|{A.RST}{' ' * W}{_cpos}{A.CYN}|{A.RST}") + out.append(f"{A.CYN}{'-' * (W + 2)}{A.RST}") + if max_scroll > 0: + pct = scroll * 100 // max_scroll if max_scroll else 100 + nav = f" {A.DIM}[j/k] Scroll [{pct}%] [b] Back{A.RST}" + else: + nav = f" {A.DIM}[b] Back to help menu{A.RST}" + out.append(f"{A.CYN}|{A.RST}{nav}{' ' * max(0, W - _vl(nav))}{_cpos}{A.CYN}|{A.RST}") + out.append(f"{A.CYN}{'=' * (W + 2)}{A.RST}") + + _w("\n".join(out) + "\n") + _fl() + key = _read_key_blocking() + if key in ("q", "esc", "b", "ctrl-c", "h"): + _w(A.SHOW) + return + if key in ("j", "down") and scroll < max_scroll: + scroll += 1 + elif key in ("k", "up") and scroll > 0: + scroll -= 1 + elif key in ("n", "pagedown"): + scroll = min(max_scroll, scroll + visible) + elif key in ("p", "pageup"): + scroll = max(0, scroll - visible) + + +def _help_getting_started() -> List[str]: + return [ "", - f" {A.BOLD}{A.CYN}Local Files (auto-detected){A.RST}", - f" {A.DIM}Place config files in the same directory where you run cfray.{A.RST}", - f" {A.DIM}Supported formats: {A.WHT}.txt .json .conf .lst{A.RST}", - f" {A.DIM}They will appear automatically under {A.WHT}LOCAL FILES{A.DIM} in the menu.{A.RST}", - f" {A.GRN}Example:{A.RST} {A.DIM}cp configs.txt /root/ && cd /root && python3 scanner.py{A.RST}", + f" {A.BOLD}{A.CYN}What is cfray?{A.RST}", + f" A Cloudflare config scanner, speed tester, and Xray server", + f" deployer. Finds the fastest CF edge IPs and the best proxy", + f" configurations for your connection.", "", - f" {A.BOLD}{A.CYN}[P] Enter File Path{A.RST}", - f" {A.DIM}Load a config file from any location by typing its full path.{A.RST}", - f" {A.GRN}Example:{A.RST} {A.DIM}/home/user/configs/my_vless.txt{A.RST}", + f" {A.BOLD}{A.CYN}How to launch{A.RST}", + f" {A.WHT}Interactive TUI:{A.RST} python3 scanner.py", + f" {A.WHT}Headless mode:{A.RST} python3 scanner.py -i file.txt --no-tui", + f" {A.WHT}Show CLI help:{A.RST} python3 scanner.py --help", "", - f" {A.CYN}{'─' * 46}{A.RST}", + f" {A.BOLD}{A.CYN}Basic workflow{A.RST}", + f" {A.WHT}1.{A.RST} Choose an input source from the main menu", + f" {A.WHT}2.{A.RST} cfray resolves domains to Cloudflare edge IPs", + f" {A.WHT}3.{A.RST} Tests TCP+TLS latency on all IPs (fast filter)", + f" {A.WHT}4.{A.RST} Speed tests the top IPs through progressive rounds", + f" {A.WHT}5.{A.RST} Results dashboard shows ranked results live", + f" {A.WHT}6.{A.RST} Export best configs — ready to use in your client", "", - f" {A.BOLD}{A.CYN}[1-9] VLESS/VMess URI file (.txt){A.RST}", - f" {A.DIM}Text file, one URI per line. Can mix VLESS and VMess.{A.RST}", - f" {A.GRN}vless://uuid@domain:443?type=ws&host=sni.com&...#name{A.RST}", + f" {A.BOLD}{A.CYN}Scoring formula{A.RST}", + f" Score = {A.WHT}latency (35%){A.RST} + {A.WHT}speed (50%){A.RST} + {A.WHT}TTFB (15%){A.RST}", + f" Higher score = better overall performance (0-100 scale)", "", - f" {A.BOLD}{A.CYN}[1-9] Domain JSON file (.json){A.RST}", - f' {A.DIM}JSON with domain+IP:{A.RST} {A.GRN}{{"data": [{{"domain":"x.ir","ipv4":"1.2.3.4"}}]}}{A.RST}', + f" {A.BOLD}{A.CYN}What gets exported{A.RST}", + f" {A.WHT}CSV file:{A.RST} IP, latency, speed, score, colo, domains", + f" {A.WHT}Top N configs:{A.RST} Best VLESS/VMess URIs ready to import", + f" {A.WHT}Full sorted:{A.RST} ALL alive configs, best to worst", + f" Files saved to {A.WHT}results/{A.RST} directory", "", - f" {A.BOLD}{A.CYN}[S] Subscription URL{A.RST}", - f" {A.DIM}Fetches VLESS/VMess configs from a remote URL (plain or base64).{A.RST}", - f" {A.GRN}https://example.com/sub.txt{A.RST}", - f" {A.DIM}CLI: python3 scanner.py --sub URL{A.RST}", + f" {A.BOLD}{A.CYN}Main menu keys{A.RST}", + f" {A.WHT}1-9{A.RST} Select a local config file", + f" {A.WHT} s {A.RST} Load from subscription URL", + f" {A.WHT} p {A.RST} Enter a custom file path", + f" {A.WHT} t {A.RST} Template + address list mode", + f" {A.WHT} f {A.RST} Find clean Cloudflare IPs", + f" {A.WHT} x {A.RST} Xray pipeline test (fragment + transport)", + f" {A.WHT} d {A.RST} Deploy Xray on a Linux VPS", + f" {A.WHT} o {A.RST} Worker Proxy (fresh workers.dev SNI)", + f" {A.WHT} c {A.RST} Connection Manager", + f" {A.WHT} h {A.RST} This help menu", + f" {A.WHT} q {A.RST} Quit", "", - f" {A.BOLD}{A.CYN}[T] Template + Address list{A.RST}", - f" {A.DIM}Give one VLESS/VMess template + a file of CF IPs/domains.{A.RST}", - f" {A.DIM}Scanner replaces the address for each entry and tests them all.{A.RST}", - f" {A.WHT}Template:{A.RST} {A.GRN}vless://uuid@ADDR:443?type=ws&...#name{A.RST}", - f" {A.WHT}Addresses:{A.RST} {A.GRN}one IP or domain per line (.txt){A.RST}", - f" {A.DIM}CLI: python3 scanner.py --template 'vless://...' -i addrs.txt{A.RST}", - "", - f" {A.BOLD}{A.CYN}[F] Find Clean Cloudflare IPs{A.RST}", - f" {A.DIM}Scans all CF IP ranges to find reachable edge IPs.{A.RST}", - f" {A.DIM}Modes: Quick (~4K), Normal (~12K), Full (~1.5M), Mega (~3M multi-port){A.RST}", - f" {A.DIM}Mega tests all IPs on ports 443+8443 for maximum coverage.{A.RST}", - f" {A.DIM}Found IPs can be saved or used with a template for speed test.{A.RST}", - f" {A.DIM}CLI: python3 scanner.py --find-clean --no-tui --clean-mode mega{A.RST}", - "", - f" {A.CYN}{'─' * 46}{A.RST}", - f" {A.BOLD}{A.CYN}How it works:{A.RST}", - f" {A.DIM}1. Resolve domains to CF edge IPs, deduplicate{A.RST}", - f" {A.DIM}2. Test TCP+TLS latency, cut bottom by latency{A.RST}", - f" {A.DIM}3. Speed test top candidates in progressive rounds{A.RST}", - f" {A.DIM}4. Score = latency 35% + speed 50% + TTFB 15%{A.RST}", - f" {A.DIM}5. Export top configs ranked by score{A.RST}", - "", - f" {A.BOLD}{A.WHT}Made By Sam - SamNet Technologies{A.RST}", - f" {A.DIM}https://github.com/SamNet-dev/cfray{A.RST}", ] - # Fit within terminal: header(3) + title(1) + sep(1) + guide + footer(3) - max_guide = rows - 8 - if max_guide < len(guide): - guide = guide[:max_guide] - for g in guide: - lines.append(draw_box_line(g, cols)) +def _help_scan_modes() -> List[str]: + return [ + "", + f" {A.BOLD}{A.CYN}Local Files (auto-detected){A.RST}", + f" Place config files in the directory where you run cfray.", + f" Supported formats: {A.WHT}.txt .json .conf .lst{A.RST}", + f" They appear automatically in the {A.WHT}LOCAL FILES{A.RST} section.", + "", + f" {A.BOLD}Text files (.txt):{A.RST}", + f" One VLESS or VMess URI per line:", + f" {A.GRN}vless://uuid@domain:443?type=ws&host=sni.com#name{A.RST}", + f" {A.GRN}vmess://base64-encoded-json{A.RST}", + "", + f" {A.BOLD}JSON files (.json):{A.RST}", + f' Domain list: {A.GRN}{{"data":[{{"domain":"x.ir","ipv4":"1.2.3.4"}}]}}{A.RST}', + "", + f" {A.BOLD}{A.CYN}[P] Enter File Path{A.RST}", + f" Load a config file from any location on disk.", + f" Type the full path when prompted.", + f" {A.GRN}Example:{A.RST} /home/user/configs/my_vless.txt", + "", + f" {A.BOLD}{A.CYN}[S] Subscription URL{A.RST}", + f" Fetches VLESS/VMess configs from a remote URL.", + f" Supports both plain text and base64-encoded content.", + f" {A.GRN}Example:{A.RST} https://example.com/sub.txt", + "", + f" {A.BOLD}How to use:{A.RST}", + f" 1. Press {A.WHT}s{A.RST} in the main menu", + f" 2. Paste your subscription URL", + f" 3. cfray fetches and parses the configs automatically", + "", + f" {A.BOLD}{A.CYN}[T] Template + Address List{A.RST}", + f" Have one working config but want to test many IPs?", + f" This mode takes your config and a file of IPs/domains,", + f" replaces the address in the config for each one, and", + f" tests them all to find the fastest.", + "", + f" {A.BOLD}How to use:{A.RST}", + f" 1. Press {A.WHT}t{A.RST} in the main menu", + f" 2. Paste your VLESS/VMess URI (the template)", + f" 3. Enter path to a .txt file with one IP per line", + f" 4. cfray generates a config for each IP and scans all", + "", + f" {A.BOLD}CLI equivalent:{A.RST}", + f" {A.GRN}python3 scanner.py --template 'vless://...' -i addrs.txt{A.RST}", + "", + f" {A.BOLD}{A.CYN}Scan rounds{A.RST}", + f" {A.WHT}Quick:{A.RST} 1 round (small download, fast)", + f" {A.WHT}Normal:{A.RST} 3 rounds (progressive: small -> large)", + f" {A.WHT}Thorough:{A.RST} 5 rounds (most accurate, slower)", + f" In each round, bottom performers are eliminated.", + f" Survivors move to the next round with a bigger download.", + "", + ] - lines.append(draw_box_line("", cols)) - lines.append(draw_box_sep(cols)) - lines.append(draw_box_line(f" {A.DIM}Press any key to go back{A.RST}", cols)) - lines.append(draw_box_bottom(cols)) - _w("\n".join(lines) + "\n") - _fl() - _read_key_blocking() +def _help_xray_test() -> List[str]: + return [ + "", + f" {A.BOLD}{A.CYN}What is Xray Pipeline Test?{A.RST}", + f" Tests your config through a {A.WHT}real Xray-core proxy tunnel{A.RST}.", + f" Unlike basic scanning, this actually routes traffic through", + f" your VPN/proxy — measuring real-world speed and latency.", + "", + f" The pipeline generates variations of your config with", + f" different fragment settings and transports, then tests", + f" each one to find the best combination.", + "", + f" {A.BOLD}{A.CYN}3-Stage Pipeline{A.RST}", + f" {A.WHT}Stage 1 — IP Scan:{A.RST}", + f" TLS probes on Cloudflare IPs to find live edges", + f" {A.WHT}Stage 2 — Base Connectivity:{A.RST}", + f" Tests your original config on discovered live IPs", + f" {A.WHT}Stage 3 — Expansion:{A.RST}", + f" Generates fragment + transport variations on working IPs", + f" and speed-tests each one to find the fastest combo", + "", + f" {A.BOLD}{A.CYN}[X] Xray Pipeline Test — step by step{A.RST}", + f" 1. Press {A.WHT}x{A.RST} in the main menu", + f" 2. Paste your working VLESS/VMess URI", + f" 3. Choose fragment preset:", + f" {A.WHT}none{A.RST}: no fragmentation", + f" {A.WHT}light{A.RST}: gentle DPI bypass (length 100-200)", + f" {A.WHT}medium{A.RST}: moderate bypass (2 settings)", + f" {A.WHT}heavy{A.RST}: aggressive bypass (3 settings)", + f" {A.WHT}all{A.RST}: tests all fragment combos", + f" 4. Choose IP source (auto CF scan or custom IPs)", + f" 5. Pipeline runs all 3 stages automatically", + f" 6. Results ranked by real proxy speed", + "", + f" {A.BOLD}{A.CYN}What are fragments?{A.RST}", + f" TLS Client Hello fragmentation splits the initial TLS", + f" handshake into small pieces. This can bypass DPI (Deep", + f" Packet Inspection) that filters traffic based on SNI.", + "", + f" {A.WHT}packets:{A.RST} tlshello (fragment the Client Hello)", + f" {A.WHT}length:{A.RST} size of each fragment (e.g. 100-200 bytes)", + f" {A.WHT}interval:{A.RST} delay between fragments (e.g. 10-20 ms)", + "", + f" Heavier fragments = more likely to bypass DPI but slower.", + f" Let cfray test all presets to find the best one for you.", + "", + f" {A.BOLD}{A.CYN}Xray binary{A.RST}", + f" Xray-core is auto-installed to {A.WHT}~/.cfray/bin/xray{A.RST}", + f" Does NOT touch your system xray installation.", + f" Use {A.WHT}--xray-install{A.RST} to force reinstall.", + "", + f" {A.BOLD}{A.CYN}CLI equivalent{A.RST}", + f" {A.GRN}python3 scanner.py --xray 'vless://...' --xray-frag all{A.RST}", + "", + ] + + +def _help_clean_finder() -> List[str]: + return [ + "", + f" {A.BOLD}{A.CYN}What is the Clean IP Finder?{A.RST}", + f" Scans Cloudflare's IP ranges to find edge servers that", + f" are reachable from your network. These 'clean' IPs can be", + f" used as the address in your proxy configs for better", + f" performance and reliability.", + "", + f" {A.BOLD}{A.CYN}How to use{A.RST}", + f" 1. Press {A.WHT}f{A.RST} in the main menu", + f" 2. Pick a scan mode:", + "", + f" {A.WHT}Quick{A.RST} ~4,000 IPs (fast, samples each /24)", + f" {A.WHT}Normal{A.RST} ~12,000 IPs (recommended, good coverage)", + f" {A.WHT}Full{A.RST} ~1.5M IPs (every IP in CF ranges)", + f" {A.WHT}Mega{A.RST} ~3M tests (all IPs x ports 443+8443)", + "", + f" 3. cfray tests TCP+TLS connectivity to each IP", + f" 4. Results show reachable IPs sorted by latency", + f" 5. Save clean IPs to a file, or continue to template scan", + "", + f" {A.BOLD}{A.CYN}What happens with the results?{A.RST}", + f" After the scan completes, you can:", + f" - {A.WHT}Save{A.RST} the clean IPs to a text file", + f" - {A.WHT}Use with template{A.RST}: pick a config URI and test each IP", + f" - {A.WHT}Use with Xray test{A.RST}: full proxy speed test on clean IPs", + "", + f" {A.BOLD}{A.CYN}Custom subnets{A.RST}", + f" By default cfray scans all official Cloudflare ranges.", + f" You can limit to specific subnets:", + f" {A.GRN}python3 scanner.py --find-clean --subnets 104.16.0.0/12{A.RST}", + f" Or provide a file with one CIDR per line:", + f" {A.GRN}python3 scanner.py --find-clean --subnets subnets.txt{A.RST}", + "", + f" {A.BOLD}{A.CYN}Validation{A.RST}", + f" cfray verifies each IP actually serves Cloudflare by", + f" checking for the {A.WHT}server: cloudflare{A.RST} response header.", + f" This filters out non-CF IPs within CF ranges.", + "", + f" {A.BOLD}{A.CYN}CLI equivalent{A.RST}", + f" {A.GRN}python3 scanner.py --find-clean --no-tui{A.RST}", + f" {A.GRN}python3 scanner.py --find-clean --clean-mode mega --no-tui{A.RST}", + "", + ] + + +def _help_deploy() -> List[str]: + return [ + "", + f" {A.BOLD}{A.CYN}[D] Deploy Xray Server{A.RST}", + f" Install and configure Xray on a Linux VPS in minutes.", + f" Generates a full server config + client URI automatically.", + "", + f" {A.BOLD}{A.CYN}How to deploy{A.RST}", + f" 1. Press {A.WHT}d{A.RST} in the main menu", + f" 2. Choose protocol: {A.WHT}VLESS{A.RST} or {A.WHT}VMess{A.RST}", + f" 3. Choose transport: {A.WHT}TCP{A.RST}, {A.WHT}WebSocket{A.RST}, {A.WHT}gRPC{A.RST}, {A.WHT}H2{A.RST}, or {A.WHT}XHTTP{A.RST}", + f" 4. Choose security:", + f" {A.WHT}REALITY{A.RST} Best for censored networks (no domain needed)", + f" {A.WHT}TLS{A.RST} Standard TLS (needs domain + certificate)", + f" {A.WHT}None{A.RST} No encryption (not recommended)", + f" 5. cfray installs Xray, generates UUID + keys", + f" 6. Outputs a ready-to-use client URI — just copy it!", + "", + f" {A.BOLD}{A.CYN}Multiple configs{A.RST}", + f" After creating the first config, the wizard asks if you", + f" want to add another. This lets you deploy e.g.:", + f" - {A.WHT}TCP + REALITY{A.RST} on port 443 (direct, fast)", + f" - {A.WHT}WS + TLS{A.RST} on port 444 (CDN-compatible)", + f" REALITY keys and TLS certs are reused across configs.", + "", + f" {A.BOLD}{A.CYN}Requirements{A.RST}", + f" - Linux VPS (Ubuntu/Debian/CentOS/Fedora)", + f" - Run as {A.WHT}root{A.RST}", + f" - Port 443 open (or your chosen port)", + "", + f" {A.BOLD}{A.CYN}[C] Connection Manager{A.RST}", + f" After deploying, use the Connection Manager to manage", + f" your server's inbounds and users.", + "", + f" {A.BOLD}Keys:{A.RST}", + f" {A.WHT}A{A.RST} Add a new inbound (new protocol/port)", + f" {A.WHT}U{A.RST} Add a user to an existing inbound", + f" {A.WHT}S{A.RST} Show all client URIs", + f" {A.WHT}V{A.RST} View inbound details (config JSON)", + f" {A.WHT}X{A.RST} Delete an inbound", + f" {A.WHT}R{A.RST} Restart Xray service", + f" {A.WHT}L{A.RST} View Xray logs", + f" {A.WHT}D{A.RST} Uninstall Xray completely", + f" {A.WHT}B{A.RST} Back to main menu", + "", + f" {A.BOLD}{A.CYN}CLI equivalent{A.RST}", + f" {A.GRN}python3 scanner.py --deploy{A.RST}", + f" {A.GRN}python3 scanner.py --deploy --deploy-security reality{A.RST}", + f" {A.GRN}python3 scanner.py --deploy --deploy-protocol vmess \\{A.RST}", + f" {A.GRN} --deploy-transport ws --deploy-security tls{A.RST}", + "", + ] + + +def _help_worker_proxy() -> List[str]: + return [ + "", + f" {A.BOLD}{A.CYN}What is Worker Proxy?{A.RST}", + f" Creates a Cloudflare Worker that proxies your traffic,", + f" giving your VLESS config a fresh {A.WHT}*.workers.dev{A.RST} SNI.", + "", + f" This is useful when your current SNI is blocked or slow.", + f" The Worker acts as a middleman on Cloudflare's CDN,", + f" routing traffic to your origin through a new hostname.", + "", + f" {A.BOLD}{A.CYN}How to use{A.RST}", + f" 1. Press {A.WHT}o{A.RST} in the main menu", + f" 2. Paste your VLESS URI (must use {A.WHT}WebSocket{A.RST} transport)", + f" 3. cfray generates a Worker script (JavaScript)", + f" 4. Deploy it on {A.WHT}dash.cloudflare.com{A.RST} -> Workers & Pages", + f" 5. Enter your Worker URL (e.g. {A.WHT}my-proxy.user.workers.dev{A.RST})", + f" 6. cfray builds a new URI with the Worker as address/SNI", + f" 7. Optionally run a pipeline test on the new config", + "", + f" {A.BOLD}{A.CYN}Requirements{A.RST}", + f" - Your config must use {A.WHT}WebSocket (ws){A.RST} transport", + f" - Free Cloudflare account (100K requests/day free tier)", + f" - TCP, gRPC, H2 transports are NOT supported by Workers", + "", + f" {A.BOLD}{A.CYN}How it works{A.RST}", + f" {A.WHT}Client{A.RST} -> {A.CYN}CF Worker{A.RST} -> {A.WHT}Origin server{A.RST}", + f" The Worker receives your WebSocket connection and forwards", + f" it to your origin, setting the correct Host header.", + f" Your ISP only sees a connection to {A.WHT}*.workers.dev{A.RST}.", + "", + ] + + +def _help_cli_reference() -> List[str]: + return [ + "", + f" {A.BOLD}{A.CYN}Input options{A.RST}", + f" {A.WHT}-i, --input FILE{A.RST} Input file (VLESS URIs or domains.json)", + f" {A.WHT}--sub URL{A.RST} Subscription URL (fetches configs)", + f" {A.WHT}--template URI{A.RST} Base VLESS/VMess URI (use with -i)", + "", + f" {A.BOLD}{A.CYN}Scan settings{A.RST}", + f" {A.WHT}-m, --mode MODE{A.RST} quick / normal / thorough", + f" {A.WHT}--rounds SPEC{A.RST} Custom, e.g. '1MB:200,5MB:50,20MB:20'", + f" {A.WHT}-w, --workers N{A.RST} Latency workers (default: 300)", + f" {A.WHT}--speed-workers N{A.RST} Download workers (default: 10)", + f" {A.WHT}--timeout SEC{A.RST} Latency timeout (default: 3)", + f" {A.WHT}--speed-timeout SEC{A.RST} Download timeout (default: 10)", + f" {A.WHT}--skip-download{A.RST} Latency only, no speed test", + "", + f" {A.BOLD}{A.CYN}Output options{A.RST}", + f" {A.WHT}--top N{A.RST} Export top N configs (0 = all)", + f" {A.WHT}--no-tui{A.RST} Headless mode (plain text output)", + f" {A.WHT}-o, --output FILE{A.RST} CSV output path", + f" {A.WHT}--output-configs FILE{A.RST} Save top URIs to file", + "", + f" {A.BOLD}{A.CYN}Clean IP Finder{A.RST}", + f" {A.WHT}--find-clean{A.RST} Find clean Cloudflare IPs", + f" {A.WHT}--clean-mode MODE{A.RST} quick / normal / full / mega", + f" {A.WHT}--subnets CIDRS{A.RST} Custom subnets (file or comma-sep)", + "", + f" {A.BOLD}{A.CYN}Xray Pipeline Test{A.RST}", + f" {A.WHT}--xray URI{A.RST} VLESS/VMess URI to test", + f" {A.WHT}--xray-frag PRESET{A.RST} none / light / medium / heavy / all", + f" {A.WHT}--xray-bin PATH{A.RST} Path to Xray binary", + f" {A.WHT}--xray-install{A.RST} Force install Xray binary", + f" {A.WHT}--xray-keep N{A.RST} Keep top N results (default: 10)", + "", + f" {A.BOLD}{A.CYN}Deploy{A.RST}", + f" {A.WHT}--deploy{A.RST} Deploy Xray on this server", + f" {A.WHT}--deploy-port N{A.RST} Port (default: 443)", + f" {A.WHT}--deploy-protocol P{A.RST} vless / vmess", + f" {A.WHT}--deploy-transport T{A.RST} tcp / ws / grpc / h2", + f" {A.WHT}--deploy-security S{A.RST} reality / tls / none", + f" {A.WHT}--deploy-sni DOMAIN{A.RST} SNI domain for REALITY/TLS", + f" {A.WHT}--deploy-cert PATH{A.RST} TLS certificate file", + f" {A.WHT}--deploy-key PATH{A.RST} TLS private key file", + f" {A.WHT}--deploy-ip IP{A.RST} Server IP (auto-detected)", + f" {A.WHT}--uninstall{A.RST} Remove everything cfray installed", + "", + f" {A.BOLD}{A.CYN}Examples{A.RST}", + "", + f" {A.DIM}# Scan a config file (headless){A.RST}", + f" {A.GRN}python3 scanner.py -i configs.txt --no-tui{A.RST}", + "", + f" {A.DIM}# Subscription URL, export top 20{A.RST}", + f" {A.GRN}python3 scanner.py --sub https://example.com/sub --top 20{A.RST}", + "", + f" {A.DIM}# Template scan: one config, many IPs{A.RST}", + f" {A.GRN}python3 scanner.py --template 'vless://...' -i ips.txt{A.RST}", + "", + f" {A.DIM}# Xray test with all fragment presets{A.RST}", + f" {A.GRN}python3 scanner.py --xray 'vless://...' --xray-frag all{A.RST}", + "", + f" {A.DIM}# Find clean IPs (mega mode, headless){A.RST}", + f" {A.GRN}python3 scanner.py --find-clean --clean-mode mega --no-tui{A.RST}", + "", + f" {A.DIM}# Deploy VLESS + REALITY on port 443{A.RST}", + f" {A.GRN}python3 scanner.py --deploy --deploy-security reality{A.RST}", + "", + f" {A.DIM}# Deploy VMess + WebSocket + TLS{A.RST}", + f" {A.GRN}python3 scanner.py --deploy --deploy-protocol vmess \\{A.RST}", + f" {A.GRN} --deploy-transport ws --deploy-security tls{A.RST}", + "", + ] + + +def tui_show_guide(): + """Multi-page help system. Shows topic hub, dispatches to sub-pages.""" + pages = [ + ("Getting Started", "First steps, basic workflow, scoring", _help_getting_started), + ("Scan & Test Modes", "File scan, subscription, template", _help_scan_modes), + ("Xray Pipeline Test", "Fragment + transport pipeline testing", _help_xray_test), + ("Clean IP Finder", "Find reachable Cloudflare edge IPs", _help_clean_finder), + ("Deploy & Server Management", "Install Xray on VPS, manage connections", _help_deploy), + ("Worker Proxy", "Fresh workers.dev SNI for any config", _help_worker_proxy), + ("CLI Reference", "All command-line flags and examples", _help_cli_reference), + ] + while True: + _w(A.CLR + A.HOME + A.HIDE) + cols, _ = term_size() + W = cols - 2 + + out: List[str] = [] + _cpos = f"\033[{W + 2}G" + def bx(c: str): + pad = " " * max(0, W - _vl(c)) + out.append(f"{A.CYN}|{A.RST}{c}{pad}{_cpos}{A.CYN}|{A.RST}") + + out.append(f"{A.CYN}{'=' * (W + 2)}{A.RST}") + t = f" {A.BOLD}{A.WHT}cfray{A.RST} {A.DIM}v{VERSION}{A.RST}" + bx(t) + out.append(f"{A.CYN}{'-' * (W + 2)}{A.RST}") + bx(f" {A.BOLD}{A.WHT}Help & Guide{A.RST}") + out.append(f"{A.CYN}{'-' * (W + 2)}{A.RST}") + bx("") + + icons = ["🚀", "📡", "⚡", "🔍", "🛠", "☁", "💻"] + for i, (title, desc, _) in enumerate(pages): + num = f" {A.CYN}{A.BOLD}{i + 1}{A.RST}" + bx(f"{num}. {icons[i]} {A.BOLD}{A.WHT}{title}{A.RST}") + bx(f" {A.DIM}{desc}{A.RST}") + bx("") + + bx(f" {A.DIM}{'─' * (W - 2)}{A.RST}") + bx(f" {A.DIM}[1-{len(pages)}] Open topic [q] Back to menu{A.RST}") + bx("") + bx(f" {A.BOLD}{A.WHT}Made By Sam — SamNet Technologies{A.RST}") + bx(f" {A.DIM}https://git.samnet.dev/SamNet-dev/cfray{A.RST}") + out.append(f"{A.CYN}{'=' * (W + 2)}{A.RST}") + + _w("\n".join(out) + "\n") + _fl() + key = _read_key_blocking() + if key in ("q", "b", "esc", "ctrl-c"): + _w(A.SHOW) + return + if key.isdigit() and 1 <= int(key) <= len(pages): + title, _, fn = pages[int(key) - 1] + _help_show_page(title, fn()) + continue def _clean_pick_mode() -> Optional[str]: @@ -1813,15 +4628,16 @@ async def tui_run_clean_finder() -> Optional[Tuple[str, str]]: cs = CleanScanState() scan_task = asyncio.ensure_future( scan_clean_ips( - ips, workers=scan_cfg["workers"], timeout=3.0, + ips, workers=scan_cfg["workers"], timeout=5.0, validate=scan_cfg["validate"], cs=cs, ports=ports, ) ) old_sigint = signal.getsignal(signal.SIGINT) + _loop = asyncio.get_running_loop() def _sig(sig, frame): cs.interrupted = True - scan_task.cancel() + _loop.call_soon_threadsafe(scan_task.cancel) signal.signal(signal.SIGINT, _sig) _w(A.CLR + A.HIDE) @@ -1829,17 +4645,22 @@ async def tui_run_clean_finder() -> Optional[Tuple[str, str]]: while not scan_task.done(): _draw_clean_progress(cs) await asyncio.sleep(0.3) - except (asyncio.CancelledError, Exception): + except asyncio.CancelledError: pass + except Exception as e: + _dbg(f"CLEAN: progress loop error: {e}") finally: signal.signal(signal.SIGINT, old_sigint) try: results = await scan_task - except (asyncio.CancelledError, Exception): + except asyncio.CancelledError: + results = sorted(cs.all_results or cs.results, key=lambda x: x[1]) + except Exception as e: + _dbg(f"CLEAN: scan_task error: {e}") results = sorted(cs.all_results or cs.results, key=lambda x: x[1]) - elapsed = _fmt_elapsed(time.monotonic() - cs.start_time) + elapsed = _fmt_elapsed(time.monotonic() - cs.start_time) if cs.start_time > 0 else "0s" _dbg(f"CLEAN: Done in {elapsed}. Found {len(results):,} / {len(ips):,}") # Show results and get user action @@ -1910,7 +4731,8 @@ def tui_pick_file() -> Optional[Tuple[str, str]]: out: List[str] = [] def bx(c: str): - out.append(f"{A.CYN}║{A.RST}" + c + " " * max(0, W - _vl(c)) + f"{A.CYN}║{A.RST}") + pad = " " * max(0, W - _vl(c)) + out.append(f"{A.CYN}║{A.RST}{c}{pad}\033[{W + 2}G{A.CYN}║{A.RST}") # Single clean box — no internal double-line separators out.append(f"{A.CYN}╔{'═' * W}╗{A.RST}") @@ -1942,6 +4764,10 @@ def tui_pick_file() -> Optional[Tuple[str, str]]: bx(f" {A.DIM}── {A.BOLD}{A.WHT}🔧 TOOLS{A.RST} {A.DIM}{'─' * max(1, W - 13)}{A.RST}") bx(f" {A.CYN}{A.BOLD}t{A.RST}. 🧩 {A.WHT}Template + Addresses{A.RST} {A.DIM}Test one config against many IPs{A.RST}") bx(f" {A.CYN}{A.BOLD}f{A.RST}. 🔍 {A.WHT}Clean IP Finder{A.RST} {A.DIM}Scan Cloudflare IP ranges{A.RST}") + bx(f" {A.CYN}{A.BOLD}x{A.RST}. ⚡ {A.WHT}Xray Pipeline Test{A.RST} {A.DIM}Smart: probe → validate → expand → speed{A.RST}") + bx(f" {A.CYN}{A.BOLD}d{A.RST}. 🚀 {A.WHT}Deploy Xray Server{A.RST} {A.DIM}Install Xray on Linux VPS{A.RST}") + bx(f" {A.CYN}{A.BOLD}o{A.RST}. ☁ {A.WHT}Worker Proxy{A.RST} {A.DIM}Fresh workers.dev SNI for any VLESS config{A.RST}") + bx(f" {A.CYN}{A.BOLD}c{A.RST}. 🔧 {A.WHT}Connection Manager{A.RST} {A.DIM}Manage existing Xray server configs{A.RST}") bx("") bx(f" {A.DIM}{'─' * (W - 2)}{A.RST}") bx(f" {A.DIM}[h] ❓ Help [q] 🚪 Quit{A.RST}") @@ -2016,6 +4842,14 @@ def tui_pick_file() -> Optional[Tuple[str, str]]: return ("template", f"{tpl}|||{addr_path}") if key == "f": return ("find_clean", "") + if key == "x": + return ("pipeline", "") + if key == "d": + return ("deploy", "") + if key == "o": + return ("worker_proxy", "") + if key == "c": + return ("connection_manager", "") if key.isdigit() and 1 <= int(key) <= len(files): return ("file", files[int(key) - 1][0]) @@ -2074,6 +4908,2890 @@ def tui_pick_mode() -> Optional[str]: return "thorough" +class XrayDashboard: + """TUI dashboard for xray proxy test progress.""" + + def __init__(self, xst: XrayTestState): + self.xst = xst + self.sort = "score" + self.offset = 0 + + def _bar(self, cur: int, tot: int, w: int = 24) -> str: + if tot == 0: + return "░" * w + p = min(1.0, cur / tot) + f = int(w * p) + return f"{A.GRN}{'█' * f}{A.DIM}{'░' * (w - f)}{A.RST}" + + def draw(self): + cols, rows = term_size() + W = cols - 2 + xst = self.xst + + # Live-score alive variations that don't have a score yet + for _v in xst.variations: + if _v.alive and _v.score == 0 and _v.connect_ms > 0: + cms = _v.connect_ms if _v.connect_ms >= 0 else 1000 + tms = _v.ttfb_ms if _v.ttfb_ms >= 0 else 1000 + _lat = max(0.0, 100.0 - cms / 10.0) + _ttfb = max(0.0, 100.0 - tms / 5.0) + if _v.native_tested or _v.speed_mbps < 0.01: + _v.score = round(_lat * 0.55 + _ttfb * 0.45, 1) + else: + _spd = min(100.0, _v.speed_mbps * 20.0) + _v.score = round(_lat * 0.35 + _spd * 0.50 + _ttfb * 0.15, 1) + + out: List[str] = [] + + def bx(c: str): + pad = " " * max(0, W - _vl(c)) + out.append(f"{A.CYN}║{A.RST}{c}{pad}\033[{W + 2}G{A.CYN}║{A.RST}") + + out.append(f"{A.CYN}╔{'═' * W}╗{A.RST}") + elapsed = _fmt_elapsed(time.monotonic() - xst.start_time) if xst.start_time else "0s" + _pipeline = getattr(xst, 'pipeline_mode', False) + title = f" {A.BOLD}{A.WHT}Xray Pipeline Test{A.RST}" if _pipeline else f" {A.BOLD}{A.WHT}Xray Proxy Test{A.RST}" + right = f"{A.DIM}{elapsed} | ^C stop{A.RST}" + bx(title + " " * max(1, W - _vl(title) - _vl(right)) + right) + out.append(f"{A.CYN}╠{'═' * W}╣{A.RST}") + + src = xst.source_uri[:60] + "..." if len(xst.source_uri) > 60 else xst.source_uri + bx(f" {A.DIM}Config:{A.RST} {src}") + bx(f" {A.DIM}Variations:{A.RST} {len(xst.variations)} " + f"{A.GRN}{xst.alive_count} alive{A.RST} " + f"{A.RED}{xst.dead_count} dead{A.RST}") + out.append(f"{A.CYN}╠{'═' * W}╣{A.RST}") + + bw = max(1, min(24, W - 50)) + _is_pipeline = getattr(xst, 'pipeline_mode', False) + + if _is_pipeline: + # 3-stage pipeline display + stage_stats = { + "ip_scan": f"{len(xst.live_ips)} CF confirmed" if xst.live_ips else "", + "base_test": (f"{len(xst.working_ips)} working" if xst.working_ips + else f"0 working" if xst.pipeline_stages[1]["status"] in ("done", "interrupted") + else ""), + "expansion": (f"{xst.quick_passed} alive" if xst.quick_passed + else f"{xst.alive_count} alive" if xst.alive_count + else f"0 alive" if xst.pipeline_stages[2]["status"] in ("done", "interrupted") + else ""), + } + for i, stage in enumerate(xst.pipeline_stages): + st = stage["status"] + label = f"{stage['label']:<18}" + stat = stage_stats.get(stage["name"], "") + if st == "done": + stat_color = A.RED if stat.startswith("0 ") else A.GRN + bx(f" {A.GRN}v{A.RST} {label} {stat_color}{stat}{A.RST}") + elif st == "active": + pct = xst.done_count * 100 // max(1, xst.total) if xst.total > 0 else 0 + bx(f" {A.GRN}>{A.RST} {A.BOLD}{label}{A.RST}" + f"[{self._bar(xst.done_count, xst.total, bw)}] " + f"{xst.done_count}/{xst.total} {pct}%") + elif st == "interrupted": + bx(f" {A.YEL}!{A.RST} {label} {A.YEL}interrupted{A.RST}") + else: + bx(f" {A.DIM}o {label} waiting...{A.RST}") + # Show pre-flight warning if present + _pf_warn = getattr(xst, 'preflight_warning', '') + if _pf_warn: + _pf_text = _pf_warn[:W - 6] if len(_pf_warn) > W - 6 else _pf_warn + bx(f" {A.YEL}! {_pf_text}{A.RST}") + elif xst.finished and xst.interrupted: + if xst.phase == "quick_filter": + bx(f" {A.YEL}!{A.RST} Quick Filter {A.YEL}interrupted ({xst.alive_count} passed){A.RST}") + elif xst.phase == "speed_test": + qp = xst.quick_passed or xst.alive_count + bx(f" {A.GRN}v{A.RST} Quick Filter {A.GRN}{qp} passed{A.RST}") + bx(f" {A.YEL}!{A.RST} Speed Test {A.YEL}interrupted{A.RST}") + else: + bx(f" {A.YEL}!{A.RST} Quick Filter {A.YEL}interrupted before starting{A.RST}") + elif xst.finished: + qp = xst.quick_passed or xst.alive_count + bx(f" {A.GRN}v{A.RST} Quick Filter {A.GRN}{qp} passed{A.RST}") + if xst.phase == "speed_test": + bx(f" {A.GRN}v{A.RST} Speed Test {A.GRN}done{A.RST}") + elif xst.phase == "quick_filter": + pct = xst.done_count * 100 // max(1, xst.total) + bx(f" {A.GRN}>{A.RST} {A.BOLD}Quick Filter{A.RST} [{self._bar(xst.done_count, xst.total, bw)}] " + f"{xst.done_count}/{xst.total} {pct}%") + elif xst.phase == "speed_test": + qp = xst.quick_passed or xst.alive_count + bx(f" {A.GRN}v{A.RST} Quick Filter {A.GRN}{qp} passed{A.RST}") + pct = xst.done_count * 100 // max(1, xst.total) + bx(f" {A.GRN}>{A.RST} {A.BOLD}Speed Test{A.RST} [{self._bar(xst.done_count, xst.total, bw)}] " + f"{xst.done_count}/{xst.total} {pct}%") + else: + bx(f" {A.DIM}o Quick Filter starting...{A.RST}") + + out.append(f"{A.CYN}╠{'═' * W}╣{A.RST}") + + # Check if we're testing multiple IPs (tag format: ip|sni|frag) + _multi_ip = any(v.tag.count("|") >= 2 for v in xst.variations[:3]) + if _multi_ip: + hdr = (f" {A.BOLD}{'#':>3} {'IP':<18} {'SNI':<20} {'Frag':>8} " + f"{'Conn':>6} {'TTFB':>6} {'Score':>5}{A.RST}") + bx(hdr) + bx(f" {A.DIM}{'─'*3} {'─'*18} {'─'*20} {'─'*8} {'─'*6} {'─'*6} {'─'*5}{A.RST}") + else: + hdr = (f" {A.BOLD}{'#':>3} {'SNI':<26} {'Fragment':>10} " + f"{'Conn':>6} {'TTFB':>6} {'Score':>5}{A.RST}") + bx(hdr) + bx(f" {A.DIM}{'─'*3} {'─'*26} {'─'*10} {'─'*6} {'─'*6} {'─'*5}{A.RST}") + + sorted_vars = sorted( + xst.variations, + key=lambda v: ( + -v.score if self.sort == "score" + else (v.connect_ms if v.connect_ms > 0 else 9999) + ), + ) + + vis = max(3, rows - 18) + page = sorted_vars[self.offset:self.offset + vis] + + for rank, v in enumerate(page, self.offset + 1): + frag_s = "none" if v.fragment is None else v.fragment.get("length", "?") + # Extract IP from tag if multi-IP mode + if _multi_ip: + _parts = v.tag.split("|", 2) + _raw_ip = _parts[0] if len(_parts) >= 3 else "" + _ip_s = (_raw_ip[:16] + "..") if len(_raw_ip) > 18 else _raw_ip[:18] + sni_short = v.sni[:20] + _name_col = f"{_ip_s:<18} {sni_short:<20} {frag_s:>8}" + else: + sni_short = v.sni[:26] + _name_col = f"{sni_short:<26} {frag_s:>10}" + if not v.alive and v.error: + _err_s = v.error[:31] if v.error else "dead" + _pad = max(0, 31 - len(_err_s)) + row = (f" {A.DIM}{rank:>3} {_name_col} " + f"{A.RED}{_err_s}{A.RST}{A.DIM}{' '*_pad}{A.RST}") + elif not v.alive and not v.error and v.connect_ms <= 0 and v.score <= 0: + row = (f" {A.DIM}{rank:>3} {_name_col} " + f"{'--':>6} {'--':>6} {'--':>5}{A.RST}") + else: + conn_s = f"{v.connect_ms:6.0f}" if v.connect_ms > 0 else f"{'--':>6}" + ttfb_s = f"{v.ttfb_ms:6.0f}" if v.ttfb_ms > 0 else f"{'--':>6}" + if v.score >= 70: + sc_s = f"{A.GRN}{v.score:5.1f}{A.RST}" + elif v.score >= 40: + sc_s = f"{A.YEL}{v.score:5.1f}{A.RST}" + elif v.score > 0: + sc_s = f"{v.score:5.1f}" + else: + sc_s = f"{'--':>5}" + row = (f" {rank:>3} {_name_col} " + f"{conn_s} {ttfb_s} {sc_s}") + bx(row) + + for _ in range(vis - len(page)): + bx("") + + out.append(f"{A.CYN}╠{'═' * W}╣{A.RST}") + if xst.finished: + if W >= 100: + footer = (f" {A.CYN}[S]{A.RST} Sort {A.CYN}[E]{A.RST} Export " + f"{A.CYN}[C]{A.RST} View URI " + f"{A.CYN}[J/K]{A.RST} Scroll {A.CYN}[N/P]{A.RST} Page " + f"{A.CYN}[B]{A.RST} Back {A.CYN}[Q]{A.RST} Quit") + bx(footer) + else: + bx(f" {A.CYN}[S]{A.RST}ort {A.CYN}[E]{A.RST}xp {A.CYN}[C]{A.RST}URI {A.CYN}[B]{A.RST}ack {A.CYN}[Q]{A.RST}uit") + bx(f" {A.CYN}[J/K]{A.RST} Scroll {A.CYN}[N/P]{A.RST} Page") + if xst.export_error: + bx(f" {A.RED}{xst.export_error}{A.RST}") + else: + bx(f" {A.DIM}{xst.phase_label} | Press Ctrl+C to stop{A.RST}") + out.append(f"{A.CYN}╚{'═' * W}╝{A.RST}") + + _w(A.CLR + A.HIDE) + _w("\n".join(out) + "\n") + _fl() + + def handle(self, key: str) -> Optional[str]: + sorts = ["score", "latency"] + if key == "s": + idx = sorts.index(self.sort) if self.sort in sorts else 0 + self.sort = sorts[(idx + 1) % len(sorts)] + self.offset = 0 + elif key in ("j", "down"): + _, rows = term_size() + vis = max(3, rows - 18) + self.offset = min(self.offset + 1, max(0, len(self.xst.variations) - vis)) + elif key in ("k", "up"): + self.offset = max(0, self.offset - 1) + elif key in ("n",): + _, rows = term_size() + vis = max(3, rows - 18) + self.offset = min(self.offset + vis, max(0, len(self.xst.variations) - vis)) + elif key in ("p",): + _, rows = term_size() + vis = max(3, rows - 18) + self.offset = max(0, self.offset - vis) + elif key == "e" and self.xst.finished: + return "export" + elif key == "c" and self.xst.finished: + return "view_uri" + elif key == "b": + return "back" + elif key in ("q", "ctrl-c"): + return "quit" + return None + + +def xray_save_results(xst: XrayTestState, top: int = 10) -> Tuple[str, str]: + """Save xray test results: CSV + top VLESS/VMess URIs. Returns (csv_path, uris_path).""" + os.makedirs(RESULTS_DIR, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + + sorted_vars = sorted( + [v for v in xst.variations if v.alive], + key=lambda v: v.score, reverse=True, + ) + + csv_path = _results_path(f"xray_{ts}_results.csv") + with open(csv_path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["Rank", "Tag", "SNI", "Fragment", "Connect_ms", "TTFB_ms", + "Speed_MBps", "Score", "Error", "URI"]) + for rank, v in enumerate(sorted_vars, 1): + frag_s = json.dumps(v.fragment) if v.fragment else "" + w.writerow([ + rank, v.tag, v.sni, frag_s, + f"{v.connect_ms:.0f}" if v.connect_ms > 0 else "", + f"{v.ttfb_ms:.0f}" if v.ttfb_ms > 0 else "", + f"{v.speed_mbps:.3f}" if v.speed_mbps > 0 else "", + f"{v.score:.1f}", v.error, v.result_uri, + ]) + + uri_path = _results_path(f"xray_{ts}_top{top}.txt") + with open(uri_path, "w", encoding="utf-8") as f: + count = 0 + for v in sorted_vars: + if count >= top: + break + if v.result_uri: + f.write(v.result_uri + "\n") + count += 1 + + return csv_path, uri_path + + +async def _run_pipeline_core( + xst: "XrayTestState", pcfg: "PipelineConfig", xray_bin: str, +) -> "XrayDashboard": + """Run pipeline test with dashboard. Returns the dashboard for post-test use.""" + xst.source_uri = pcfg.uri + xst.xray_bin = xray_bin + + xdash = XrayDashboard(xst) + + async def _pipeline_refresh(): + while not xst.finished: + try: + xdash.draw() + except (OSError, ValueError): + pass + await asyncio.sleep(0.3) + + _w(A.CLR + A.HOME + A.HIDE) + refresh_task = asyncio.create_task(_pipeline_refresh()) + pipeline_task = asyncio.ensure_future(xray_pipeline_test(xst, pcfg)) + + old_sigint = signal.getsignal(signal.SIGINT) + _loop_pl = asyncio.get_running_loop() + + def _sig(sig, frame): + xst.interrupted = True + xst.finished = True + _loop_pl.call_soon_threadsafe(pipeline_task.cancel) + signal.signal(signal.SIGINT, _sig) + + try: + await pipeline_task + except (asyncio.CancelledError, KeyboardInterrupt): + xst.interrupted = True + xst.finished = True + _xray_calc_scores(xst) + except Exception as e: + _dbg(f"pipeline exception: {e}") + xst.interrupted = True + xst.finished = True + _xray_calc_scores(xst) + finally: + signal.signal(signal.SIGINT, old_sigint) + + refresh_task.cancel() + try: + await refresh_task + except asyncio.CancelledError: + pass + + return xdash + + +async def _post_pipeline_results( + xst: "XrayTestState", xdash: "XrayDashboard", args, +) -> None: + """Post-test results: auto-export, interactive loop (export, view).""" + top_n = getattr(args, "xray_keep", 10) + csv_p = uri_p = "" + + # Auto-export alive results + if any(v.alive for v in xst.variations): + try: + csv_p, uri_p = xray_save_results(xst, top=top_n) + except Exception as e: + csv_p = uri_p = "" + xst.export_error = f"Export failed: {e}" + + # Show final dashboard + xdash.draw() + + # -- Post-scan interactive loop -- + try: + while True: + key = _read_key_nb(0.1) + if key is None: + continue + act = xdash.handle(key) + if act in ("quit", "back"): + break + elif act == "export": + try: + csv_p, uri_p = xray_save_results(xst, top=top_n) + _n_alive = sum(1 for v in xst.variations if v.alive) + xst.export_error = f"Exported {_n_alive} configs -> {uri_p}" + except Exception as e: + xst.export_error = f"Export failed: {e}" + elif act == "view_uri": + alive = sorted( + [v for v in xst.variations if v.alive], + key=lambda v: v.score, reverse=True, + ) + if alive and alive[0].result_uri: + while True: + _w(A.CLR + A.HOME + A.SHOW) + _w(f"\n {A.BOLD}Top configs ({len(alive)} alive):{A.RST}\n\n") + for _vi, _vv in enumerate(alive[:10], 1): + _vc = A.GRN if _vi == 1 else A.CYN + _conn_s = f"conn={_vv.connect_ms:.0f}ms" if _vv.connect_ms > 0 else "" + _w(f" {A.BOLD}#{_vi:<3}{A.RST} " + f"{_vc}{_vv.sni:<28}{A.RST} " + f"score={_vv.score:<6.1f} " + f"{_conn_s}\n") + if len(alive) > 10: + _w(f" {A.DIM}... +{len(alive) - 10} more{A.RST}\n") + _w(f"\n") + if csv_p: + _w(f" {A.DIM}Full results: {csv_p}{A.RST}\n") + if uri_p: + _w(f" {A.DIM}Top URIs: {uri_p}{A.RST}\n") + _w(f"\n {A.YEL}Enter #{A.RST} to view full URI" + f" {A.DIM}(or press Enter to go back):{A.RST} ") + _fl() + try: + _choice = input().strip() + except (EOFError, KeyboardInterrupt): + _choice = "" + if not _choice: + break + try: + _idx = int(_choice.lstrip("#")) - 1 + if 0 <= _idx < len(alive) and alive[_idx].result_uri: + _conn_s2 = f"conn={alive[_idx].connect_ms:.0f}ms" if alive[_idx].connect_ms > 0 else "" + _w(f"\n {A.BOLD}#{_idx + 1} " + f"(score={alive[_idx].score:.1f}" + f"{', ' + _conn_s2 if _conn_s2 else ''}):" + f"{A.RST}\n\n") + _w(f" {A.GRN}{alive[_idx].result_uri}{A.RST}\n") + else: + _w(f"\n {A.RED}No config #{_choice} " + f"(1-{len(alive)} available){A.RST}\n") + except ValueError: + _w(f"\n {A.RED}Enter a number 1-{len(alive)}{A.RST}\n") + _w(f"\n {A.DIM}Press any key to continue...{A.RST}\n") + _fl() + _read_key_blocking() + _w(A.HIDE) + else: + xst.export_error = "No alive configs to view" + xdash.draw() + except (KeyboardInterrupt, EOFError, OSError): + pass + _w(A.SHOW) + + +def tui_pipeline_input(configless: bool = False) -> Optional[PipelineConfig]: + """Unified input wizard for the progressive xray pipeline. + + Config mode: paste URI -> pick SNIs/frags/transports + Returns PipelineConfig or None (cancelled). + """ + _w(A.SHOW) + + # -- Config mode -- + _w(f"\n {A.BOLD}{A.CYN}Xray Pipeline Test{A.RST}\n") + _w(f" {A.YEL}For:{A.RST} You have a working config {A.WHT}behind Cloudflare{A.RST} and want to find the fastest IPs and fragment settings.\n") + _w(f" {A.DIM}Smart: probe IPs -> validate config -> expand (IPs x fragments){A.RST}\n\n") + + # Step 1: URI + _w(f" {A.BOLD}Step 1:{A.RST} {A.CYN}Paste your VLESS/VMess config URI:{A.RST}\n") + _w(f" {A.DIM}(must be behind Cloudflare -- CDN, Tunnel, or Workers){A.RST}\n ") + _fl() + try: + uri = input().strip() + except (EOFError, KeyboardInterrupt): + return None + parsed = parse_vless_full(uri) or parse_vmess_full(uri) + if not parsed: + _w(f" {A.RED}Invalid VLESS/VMess URI.{A.RST}\n"); _fl() + time.sleep(1.5); return None + + _proto = parsed.get("protocol", "vless") + _net = parsed.get("type") or parsed.get("net") or "tcp" + _sec = parsed.get("security") or "none" + _addr = parsed.get("address", "?") + _port = parsed.get("port", "?") + _is_reality = _sec == "reality" + _no_tls = _sec in ("none", "") + _is_cf = _is_cf_address(_addr) + # For domain addresses (CDN fronting like chatgpt.com), resolve DNS + if not _is_cf and not _is_reality and not _no_tls: + _is_cf = _resolve_is_cf(_addr) + + _mode_label = "Cloudflare" if _is_cf else ("REALITY" if _is_reality else "Direct") + _w(f" {A.GRN}OK{A.RST} {_proto}/{_net}/{_sec} @ {_addr}:{_port}" + f" {A.DIM}({_mode_label}){A.RST}\n") + _fl() + + # Block non-CF, non-REALITY configs -- they can't benefit from the pipeline + if not _is_cf and not _is_reality: + _w(f"\n {A.RED}{'─' * 50}{A.RST}\n") + _w(f" {A.BOLD}{A.RED}Server is not behind Cloudflare{A.RST}\n") + _w(f" {A.RED}{'─' * 50}{A.RST}\n\n") + _w(f" {A.DIM}The pipeline scanner works by rotating Cloudflare IPs, SNIs,{A.RST}\n") + _w(f" {A.DIM}and fragment settings. This only works when your server is{A.RST}\n") + _w(f" {A.DIM}behind the Cloudflare CDN.{A.RST}\n\n") + _w(f" {A.DIM}Press any key to go back...{A.RST}\n") + _fl() + _read_key_blocking() + return None + + # REALITY: skip SNI/frag/transport config -- only test original IP + if _is_reality: + _w(f"\n {A.DIM}REALITY config -- testing with original SNI, no fragments.{A.RST}\n") + _w(f" {A.DIM}Pipeline will validate connectivity on the original server.{A.RST}\n") + _fl() + return PipelineConfig( + uri=uri, parsed=parsed, + sni_pool=[], frag_preset="none", + transport_variants=[], + ) + + # No SNI rotation -- CF zone matching means only the original Host SNI works. + sni_pool = [] + + # Step 2: Fragment preset + _w(f"\n {A.BOLD}Step 2:{A.RST} {A.CYN}Fragment settings (DPI bypass):{A.RST}\n") + _w(f" {A.CYN}1{A.RST}. All presets (none + light + medium + heavy) {A.GRN}(recommended){A.RST}\n") + _w(f" {A.CYN}2{A.RST}. No fragmentation\n") + _w(f" {A.CYN}3{A.RST}. Light only\n") + _w(f" {A.CYN}4{A.RST}. Heavy only\n") + _w(f" Choice [1]: ") + _fl() + try: + frag_ch = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + return None + frag_map = {"1": "all", "2": "none", "3": "light", "4": "heavy"} + frag_preset = frag_map.get(frag_ch, "all") + + # Transport: locked to original -- server/tunnel only supports what it's configured for + transport_variants = [] + _w(f"\n {A.DIM}Transport: {A.WHT}{_net}{A.RST}{A.DIM} (from config -- only testing {_net}){A.RST}\n") + + # Step 3: Custom IPs + _w(f"\n {A.BOLD}Step 3:{A.RST} {A.CYN}IP source:{A.RST}\n") + _w(f" {A.CYN}1{A.RST}. Random CF IPs ({len(CF_TEST_IPS)} IPs across all ranges) {A.GRN}(recommended){A.RST}\n") + # Check if clean_ips.txt exists from Clean IP Finder + _clean_ip_path = os.path.join(RESULTS_DIR, "clean_ips.txt") + _clean_count = 0 + if os.path.isfile(_clean_ip_path): + try: + with open(_clean_ip_path, "r") as _cf: + _clean_count = sum(1 for l in _cf if l.strip() and not l.startswith("#")) + except OSError: + pass + if _clean_count > 0: + _w(f" {A.CYN}2{A.RST}. Clean IP Finder results ({_clean_count} IPs from {_clean_ip_path})\n") + else: + _w(f" {A.CYN}2{A.RST}. Clean IP Finder results {A.DIM}(none found -- run [f] first){A.RST}\n") + _w(f" {A.CYN}3{A.RST}. Load from file path\n") + _w(f" {A.CYN}4{A.RST}. Enter IPs/CIDRs manually\n") + _w(f" Choice [1]: ") + _fl() + try: + ip_ch = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + return None + + custom_ips: List[str] = [] + if ip_ch == "2": + if _clean_count > 0: + custom_ips = expand_custom_ips(_clean_ip_path) + if custom_ips: + _w(f" {A.GRN}Loaded {len(custom_ips)} IPs from clean_ips.txt{A.RST}\n") + _fl() + else: + _w(f" {A.RED}Failed to read clean_ips.txt{A.RST}\n"); _fl() + time.sleep(1); return None + else: + _w(f" {A.RED}No clean IPs found. Run Clean IP Finder [f] from the main menu first.{A.RST}\n") + _fl(); time.sleep(2); return None + elif ip_ch == "3": + _w(f" {A.CYN}Enter file path:{A.RST}\n ") + _w(f" {A.DIM}e.g. results/clean_ips.txt or /path/to/ips.txt{A.RST}\n ") + _fl() + try: + raw_ips = input().strip() + except (EOFError, KeyboardInterrupt): + return None + if raw_ips: + custom_ips = expand_custom_ips(raw_ips) + if not custom_ips: + _w(f" {A.RED}No valid IPs found in file.{A.RST}\n"); _fl() + time.sleep(1); return None + _w(f" {A.GRN}Loaded {len(custom_ips)} IPs{A.RST}\n") + _fl() + else: + _w(f" {A.RED}No path entered.{A.RST}\n"); _fl() + time.sleep(1); return None + elif ip_ch == "4": + _w(f" {A.CYN}Enter IPs, CIDRs (comma-separated):{A.RST}\n ") + _w(f" {A.DIM}e.g. 104.16.0.0/24, 172.67.1.1{A.RST}\n ") + _fl() + try: + raw_ips = input().strip() + except (EOFError, KeyboardInterrupt): + return None + if raw_ips: + custom_ips = expand_custom_ips(raw_ips) + if not custom_ips: + _w(f" {A.RED}No valid IPs found.{A.RST}\n"); _fl() + time.sleep(1); return None + _w(f" {A.GRN}Expanded to {len(custom_ips)} IPs{A.RST}\n") + _fl() + else: + _w(f" {A.RED}No IPs entered.{A.RST}\n"); _fl() + time.sleep(1); return None + + # Step 4: Ports to scan + _orig_port = int(parsed.get("port", 443)) + _w(f"\n {A.BOLD}Step 4:{A.RST} {A.CYN}Ports to scan per IP:{A.RST}\n") + _w(f" {A.CYN}1{A.RST}. Original port ({_orig_port}) only {A.GRN}(recommended){A.RST}\n") + _w(f" {A.CYN}2{A.RST}. All CF HTTPS ports (443, 8443, 2053, 2083, 2087, 2096)\n") + _w(f" {A.CYN}3{A.RST}. Custom ports\n") + _w(f" Choice [1]: ") + _fl() + try: + port_ch = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + return None + + if port_ch == "2": + probe_ports = list(CF_HTTPS_PORTS) + if _orig_port not in probe_ports: + probe_ports.insert(0, _orig_port) + elif port_ch == "3": + _w(f" {A.CYN}Enter ports (comma-separated):{A.RST} ") + _fl() + try: + raw_ports = input().strip() + except (EOFError, KeyboardInterrupt): + return None + probe_ports = [] + for p in raw_ports.split(","): + p = p.strip() + if p.isdigit() and 1 <= int(p) <= 65535: + probe_ports.append(int(p)) + if not probe_ports: + _w(f" {A.RED}No valid ports. Using {_orig_port}.{A.RST}\n"); _fl() + probe_ports = [_orig_port] + else: + probe_ports = [_orig_port] + + # Step 5: Test intensity (max variations in expansion) + _n_frags = len(XRAY_FRAG_PRESETS.get(frag_preset, XRAY_FRAG_PRESETS.get("all", []))) + _potential = 120 * max(1, _n_frags) + _w(f"\n {A.BOLD}Step 5:{A.RST} {A.CYN}Test intensity:{A.RST}\n") + _w(f" {A.DIM}How many IP x fragment combinations to test in expansion.{A.RST}\n") + _w(f" {A.DIM}More = better coverage but takes longer.{A.RST}\n\n") + _w(f" {A.CYN}1{A.RST}. {A.WHT}Quick{A.RST} 500 variations {A.DIM}~2-3 min{A.RST}\n") + _w(f" {A.CYN}2{A.RST}. {A.WHT}Normal{A.RST} 1,500 variations {A.DIM}~5-8 min{A.RST} {A.GRN}(recommended){A.RST}\n") + _w(f" {A.CYN}3{A.RST}. {A.WHT}Thorough{A.RST} 3,000 variations {A.DIM}~10-15 min{A.RST}\n") + _w(f" {A.CYN}4{A.RST}. {A.WHT}Maximum{A.RST} 7,500 variations {A.DIM}~25-40 min{A.RST}\n") + _w(f"\n Choice [2]: ") + _fl() + try: + _int_ch = input().strip() or "2" + except (EOFError, KeyboardInterrupt): + return None + _int_map = {"1": 500, "2": 1500, "3": 3000, "4": 7500} + max_expansion = _int_map.get(_int_ch, 1500) + _w(f" {A.GRN}-> Up to {max_expansion:,} variations{A.RST}\n") + + return PipelineConfig( + uri=uri, parsed=parsed, + sni_pool=sni_pool, + frag_preset=frag_preset, + transport_variants=transport_variants, + custom_ips=custom_ips, + probe_ports=probe_ports, + max_expansion=max_expansion, + ) + + +async def _tui_run_pipeline(args, cli_uri: str = ""): + """Run the progressive xray pipeline in TUI mode.""" + + # -- Input -- + if cli_uri: + parsed = parse_vless_full(cli_uri) or parse_vmess_full(cli_uri) + if not parsed: + _w(A.SHOW) + print(f" Invalid VLESS/VMess URI: {cli_uri[:60]}...") + time.sleep(2) + return + # Block non-CF, non-REALITY configs + _addr = parsed.get("address", "") + _sec = parsed.get("security") or "none" + _is_cf_smart = _is_cf_address(_addr) or ( + _sec not in ("reality", "none", "") and _resolve_is_cf(_addr)) + if not _is_cf_smart and _sec != "reality": + _w(A.SHOW) + _w(f"\n {A.RED}Server is not behind Cloudflare.{A.RST}\n") + _w(f"\n {A.DIM}Press any key to go back...{A.RST}\n") + _fl() + _read_key_blocking() + return + # No SNI rotation -- CF zone matching blocks cross-zone SNIs. + sni_pool = [] + if getattr(args, "xray_sni", None): + sni_pool = [s.strip() for s in args.xray_sni.split(",") if s.strip()] + frag_preset = getattr(args, "xray_frag", "all") + # REALITY: no frag/transport expansion + if _sec == "reality": + frag_preset = "none" + transport_vars = [] + else: + transport_vars = ["ws", "xhttp"] + pcfg = PipelineConfig( + uri=cli_uri, parsed=parsed, + sni_pool=sni_pool, frag_preset=frag_preset, + transport_variants=transport_vars, + max_expansion=1500, + ) + else: + pcfg = tui_pipeline_input() + if pcfg is None: + return + + # -- Xray binary -- + _w(A.SHOW) + _w(f"\n {A.DIM}Looking for xray-core binary...{A.RST}\n") + _fl() + xray_bin = xray_find_binary(getattr(args, "xray_bin", None)) + if not xray_bin: + _w(f" {A.YEL}Xray not found. Installing to ~/.cfray/bin/...{A.RST}\n") + _fl() + xray_bin = xray_install() + if not xray_bin: + _w(f" {A.RED}ERROR: Could not install xray-core.{A.RST}\n") + _fl() + time.sleep(3) + return + _w(f" {A.GRN}OK{A.RST} Using {xray_bin}\n") + _fl() + + # -- Pipeline execution -- + xst = XrayTestState() + xdash = await _run_pipeline_core(xst, pcfg, xray_bin) + await _post_pipeline_results(xst, xdash, args) + + +# ─── Xray Server Deploy — Core Functions ────────────────────────────────────── + + +def deploy_check_prerequisites() -> Tuple[bool, str]: + """Check that we're on Linux as root with systemd.""" + if sys.platform != "linux": + return False, f"Server deploy is Linux-only (detected: {sys.platform})" + try: + if os.geteuid() != 0: + return False, "Must run as root (try: sudo python3 scanner.py --deploy)" + except AttributeError: + return False, "Cannot detect root status" + if not shutil.which("systemctl"): + return False, "systemd not found (systemctl not in PATH)" + return True, "" + + +def deploy_detect_server_ip() -> str: + """Detect server's public IP by querying external services.""" + for url in ("https://ifconfig.me/ip", "https://api.ipify.org", "https://icanhazip.com"): + try: + req = urllib.request.Request(url, headers={"User-Agent": "curl/7.0"}) + with urllib.request.urlopen(req, timeout=5) as resp: + ip = resp.read(1024).decode().strip() + if ip: + try: + ipaddress.ip_address(ip) + return ip + except ValueError: + continue + except (OSError, ValueError, http.client.HTTPException): + continue + return "" + + +def deploy_check_port(port: int) -> bool: + """Check if a TCP port is free.""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("", port)) + return True + except OSError: + return False + finally: + s.close() + + +def deploy_generate_uuid() -> str: + """Generate a random UUID v4.""" + import uuid as _uuid_mod + return str(_uuid_mod.uuid4()) + + +def deploy_generate_reality_keys(xray_bin: str) -> Tuple[str, str]: + """Generate x25519 key pair using xray binary. Returns (private, public). + + Handles both output formats: + - Old: "Private key: xxx\\nPublic key: yyy" + - New: "PrivateKey: xxx\\nPassword: yyy" (Password = public key) + """ + try: + kw = {} + if sys.platform == "win32": + kw["creationflags"] = 0x08000000 + result = subprocess.run( + [xray_bin, "x25519"], + capture_output=True, text=True, timeout=10, **kw, + ) + if result.returncode != 0: + return "", "" + private_key = "" + public_key = "" + for line in result.stdout.strip().splitlines(): + line = line.strip() + low = line.lower() + if low.startswith("private key:") or low.startswith("privatekey:"): + private_key = line.split(":", 1)[1].strip() + elif low.startswith("public key:") or low.startswith("publickey:"): + public_key = line.split(":", 1)[1].strip() + elif low.startswith("password:"): + # New xray format: "Password" is the public key + public_key = line.split(":", 1)[1].strip() + return private_key, public_key + except (OSError, subprocess.SubprocessError, ValueError): + return "", "" + + +def deploy_generate_short_id() -> str: + """Generate a random short ID (8 hex chars) for REALITY.""" + return os.urandom(4).hex() + + +def _build_single_inbound(parsed: dict, ds: "DeployState", index: int) -> dict: + """Build a single server inbound from a parsed client config dict.""" + protocol = parsed.get("protocol", "vless") + if protocol not in ("vless", "vmess"): + raise ValueError(f"Unsupported protocol: {protocol}") + try: + raw_port = ds.listen_port if index == 0 else int(parsed.get("port", 443)) + except (ValueError, TypeError): + raw_port = 443 + port = raw_port if 1 <= raw_port <= 65535 else 443 + + inbound: dict = { + "tag": f"inbound-{index}", + "port": port, + "listen": "::", + "protocol": protocol, + "settings": {}, + "streamSettings": {}, + "sniffing": {"enabled": True, "destOverride": ["http", "tls", "quic"]}, + } + + # -- Settings (clients) -- + uuid_val = parsed.get("uuid", "") + if not uuid_val: + uuid_val = deploy_generate_uuid() + if protocol == "vmess": + try: + alter_id = int(parsed.get("aid", 0)) + except (ValueError, TypeError): + alter_id = 0 + inbound["settings"] = { + "clients": [{ + "id": uuid_val, + "alterId": alter_id, + }], + } + else: # vless + client: dict = {"id": uuid_val} + flow = parsed.get("flow", "") + if flow: + client["flow"] = flow + inbound["settings"] = { + "clients": [client], + "decryption": "none", + } + + # -- Stream Settings -- + net = parsed.get("type", "tcp") + sec = parsed.get("security", "none") + stream: dict = {"network": net, "security": sec} + + # Security layer + if sec == "reality": + sni_val = parsed.get("sni", "") or "www.google.com" + stream["realitySettings"] = { + "show": False, + "dest": f"{sni_val}:443", + "xver": 0, + "serverNames": [sni_val], + "privateKey": ds.reality_private_key, + "shortIds": [ds.reality_short_id or ""], + } + elif sec == "tls": + tls_settings: dict = { + "certificates": [{ + "certificateFile": ds.tls_cert_path or "/usr/local/etc/xray/cert.pem", + "keyFile": ds.tls_key_path or "/usr/local/etc/xray/key.pem", + }], + } + alpn = parsed.get("alpn", "") + if alpn: + tls_settings["alpn"] = alpn.split(",") + stream["tlsSettings"] = tls_settings + + # Transport layer + if net == "ws": + ws_cfg: dict = {"path": parsed.get("path", "/")} + stream["wsSettings"] = ws_cfg + elif net == "grpc": + sn = parsed.get("serviceName") or parsed.get("path", "") + if sn == "/": + sn = "" + stream["grpcSettings"] = {"serviceName": sn or "grpc"} + elif net in ("h2", "http"): + host_val = parsed.get("host") or parsed.get("sni", "") + stream["httpSettings"] = { + "host": [host_val] if host_val else [], + "path": parsed.get("path", "/"), + } + elif net in ("xhttp", "splithttp"): + stream["network"] = "xhttp" + xhttp_cfg: dict = {"path": parsed.get("path", "/xhttp")} + mode = parsed.get("mode", "") + if mode and mode != "auto": + xhttp_cfg["mode"] = mode + stream["xhttpSettings"] = xhttp_cfg + elif net == "tcp": + htype = parsed.get("headerType", "") + if htype == "http": + stream["tcpSettings"] = { + "header": { + "type": "http", + "response": { + "version": "1.1", + "status": "200", + "reason": "OK", + }, + }, + } + + inbound["streamSettings"] = stream + return inbound + + +def build_server_config(ds: "DeployState") -> dict: + """Build Xray server JSON config from DeployState.""" + config = { + "log": {"loglevel": "warning"}, + "inbounds": [], + "outbounds": [ + {"tag": "direct", "protocol": "freedom"}, + {"tag": "block", "protocol": "blackhole"}, + ], + "routing": { + "domainStrategy": "AsIs", + "rules": [ + {"type": "field", "ip": ["geoip:private"], "outboundTag": "block"}, + ], + }, + } + + for i, parsed in enumerate(ds.parsed_configs): + inbound = _build_single_inbound(parsed, ds, i) + config["inbounds"].append(inbound) + + ds.server_config = config + return config + + +def build_client_uri_for_server(parsed: dict, ds: "DeployState", tag: str, index: int = 0) -> str: + """Build a client URI pointing to the deployed server.""" + p = copy.copy(parsed) + p["address"] = ds.server_ip + try: + p["port"] = int(ds.listen_port if index == 0 else parsed.get("port", 443)) + except (ValueError, TypeError): + p["port"] = 443 + if p.get("security") == "reality" and ds.reality_public_key: + p["pbk"] = ds.reality_public_key + if p.get("security") == "reality" and ds.reality_short_id: + p["sid"] = ds.reality_short_id + + sni = p.get("sni") or p.get("host") or "" + return _build_uri(p, sni, tag) + + +def deploy_fresh_config( + protocol: str, transport: str, security: str, + port: int, uuid_val: str, sni: str, ds: "DeployState", +) -> dict: + """Generate a fresh parsed-config dict for from-scratch deployment.""" + parsed = { + "protocol": protocol, + "uuid": uuid_val, + "address": ds.server_ip, + "port": port, + "name": f"cfray-{protocol}-{transport}", + "type": transport, + "security": security, + "sni": sni, + "host": sni, + "path": "/ws" if transport == "ws" else ("/xhttp" if transport in ("xhttp", "splithttp") else ("/" if transport in ("h2", "http") else "")), + "fp": "chrome", + "flow": "xtls-rprx-vision" if (protocol == "vless" and security == "reality" and transport == "tcp") else "", + "alpn": "h2,http/1.1" if security == "tls" else "", + "encryption": "none", + "serviceName": "grpc" if transport == "grpc" else "", + "headerType": "", + "mode": "auto" if transport in ("xhttp", "splithttp") else "", + "pbk": ds.reality_public_key if security == "reality" else "", + "sid": ds.reality_short_id if security == "reality" else "", + "spx": "", + } + if protocol == "vmess": + parsed["aid"] = 0 + parsed["scy"] = "auto" + return parsed + + +def generate_configless_base( + server: str, port: int, uuid_val: str, protocol: str = "vless", +) -> List[Tuple[str, dict]]: + """Generate base (uri, parsed) configs for config-less pipeline mode. + + Creates ws/tls and xhttp/tls variants (plus vmess/ws/tls if vmess protocol). + Returns list of (uri_string, parsed_dict) tuples. + """ + results: List[Tuple[str, dict]] = [] + default_sni = "speed.cloudflare.com" + + transports = ["ws", "xhttp"] + for transport in transports: + path = "/ws" if transport == "ws" else "/xhttp" + parsed = { + "protocol": protocol, + "uuid": uuid_val, + "address": server, + "port": port, + "name": f"cfray-{protocol}-{transport}", + "type": transport, + "security": "tls", + "sni": default_sni, + "host": default_sni, + "path": path, + "fp": "chrome", + "flow": "", + "alpn": "h2,http/1.1", + "encryption": "none", + "serviceName": "", + "headerType": "", + "pbk": "", + "sid": "", + "spx": "", + "mode": "auto" if transport == "xhttp" else "", + } + if protocol == "vmess": + parsed["aid"] = 0 + parsed["scy"] = "auto" + uri = _build_uri(parsed, default_sni, parsed["name"]) + results.append((uri, parsed)) + + # If VMess, also add a VLESS ws/tls variant for broader testing + if protocol == "vmess": + vless_parsed = { + "protocol": "vless", "uuid": uuid_val, + "address": server, "port": port, + "name": "cfray-vless-ws", "type": "ws", + "security": "tls", "sni": default_sni, "host": default_sni, + "path": "/ws", "fp": "chrome", "flow": "", + "alpn": "h2,http/1.1", "encryption": "none", + "serviceName": "", "headerType": "", + "pbk": "", "sid": "", "spx": "", "mode": "", + } + vless_uri = build_vless_uri(vless_parsed, default_sni, "cfray-vless-ws") + results.append((vless_uri, vless_parsed)) + + return results + + +# ─── Xray Server Deploy — Pipeline Functions ───────────────────────────── + + +def deploy_install_xray_system() -> Tuple[bool, str]: + """Install xray to /usr/local/bin/ with geo files. Returns (ok, message).""" + if os.path.isfile(DEPLOY_XRAY_BIN): + try: + result = subprocess.run([DEPLOY_XRAY_BIN, "version"], + capture_output=True, text=True, timeout=5) + if result.returncode == 0: + ver = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "unknown" + return True, f"Xray already installed: {ver}" + except (OSError, subprocess.SubprocessError): + pass + + local_bin = xray_find_binary() + if not local_bin: + local_bin = xray_install() + if not local_bin: + return False, "Failed to download Xray binary" + + try: + os.makedirs(os.path.dirname(DEPLOY_XRAY_BIN), exist_ok=True) + shutil.copy2(local_bin, DEPLOY_XRAY_BIN) + os.chmod(DEPLOY_XRAY_BIN, 0o755) + except OSError as e: + return False, f"Failed to install to {DEPLOY_XRAY_BIN}: {e}" + + try: + os.makedirs(DEPLOY_XRAY_SHARE, exist_ok=True) + for gf in ("geoip.dat", "geosite.dat"): + src = os.path.join(XRAY_BIN_DIR, gf) + if os.path.isfile(src): + shutil.copy2(src, os.path.join(DEPLOY_XRAY_SHARE, gf)) + except OSError: + pass # geo files are optional + + return True, f"Installed to {DEPLOY_XRAY_BIN}" + + +def deploy_write_config(ds: "DeployState") -> Tuple[bool, str]: + """Write server config JSON with backup of existing.""" + try: + os.makedirs(DEPLOY_XRAY_CONFIG_DIR, exist_ok=True) + if os.path.isfile(DEPLOY_XRAY_CONFIG): + os.makedirs(DEPLOY_XRAY_BACKUP_DIR, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + backup = os.path.join(DEPLOY_XRAY_BACKUP_DIR, f"config_{ts}.json") + shutil.copy2(DEPLOY_XRAY_CONFIG, backup) + + config_str = json.dumps(ds.server_config, indent=2, ensure_ascii=False) + tmp_path = DEPLOY_XRAY_CONFIG + ".tmp" + fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(config_str + "\n") + os.replace(tmp_path, DEPLOY_XRAY_CONFIG) + except BaseException: + try: + os.remove(tmp_path) + except OSError: + pass + raise + return True, DEPLOY_XRAY_CONFIG + except OSError as e: + return False, f"Failed to write config: {e}" + + +def deploy_validate_config() -> Tuple[bool, str]: + """Run xray to validate the config file.""" + try: + result = subprocess.run( + [DEPLOY_XRAY_BIN, "run", "-test", "-c", DEPLOY_XRAY_CONFIG], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + return True, "Config validated OK" + err_msg = (result.stderr or result.stdout).strip()[:200] + return False, f"Config validation failed: {err_msg}" + except FileNotFoundError: + return False, "Xray binary not found — cannot validate config" + except (OSError, subprocess.SubprocessError) as e: + return False, f"Validation error: {e}" + + +def deploy_setup_certbot(domain: str) -> Tuple[bool, str, str]: + """Try to obtain TLS cert via certbot. Returns (ok, cert_path, key_path).""" + if not domain: + return False, "", "" + # Validate domain: must look like a hostname (no flags, no special chars) + if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?$', domain): + return False, "", "" + # Certbot standalone needs port 80 + if not deploy_check_port(80): + return False, "", "" + certbot = shutil.which("certbot") + if not certbot: + for cmd in ( + ["apt-get", "install", "-y", "certbot"], + ["yum", "install", "-y", "certbot"], + ["dnf", "install", "-y", "certbot"], + ): + try: + result = subprocess.run(cmd, capture_output=True, timeout=120) + if result.returncode == 0: + certbot = shutil.which("certbot") + break + except (OSError, subprocess.SubprocessError): + continue + + if not certbot: + return False, "", "" + + try: + result = subprocess.run( + [certbot, "certonly", "--standalone", "--agree-tos", + "--register-unsafely-without-email", "-d", domain, + "--non-interactive"], + capture_output=True, text=True, timeout=120, + ) + if result.returncode == 0: + cert = f"/etc/letsencrypt/live/{domain}/fullchain.pem" + key = f"/etc/letsencrypt/live/{domain}/privkey.pem" + if os.path.isfile(cert) and os.path.isfile(key): + # Copy certs to xray config dir for reliable access + try: + os.makedirs(DEPLOY_XRAY_CONFIG_DIR, exist_ok=True) + dst_cert = os.path.join(DEPLOY_XRAY_CONFIG_DIR, "cert.pem") + dst_key = os.path.join(DEPLOY_XRAY_CONFIG_DIR, "key.pem") + shutil.copy2(cert, dst_cert) + shutil.copy2(key, dst_key) + os.chmod(dst_cert, 0o644) + os.chmod(dst_key, 0o600) + return True, dst_cert, dst_key + except OSError: + return True, cert, key + return False, "", "" + except (OSError, subprocess.SubprocessError): + return False, "", "" + + +def deploy_systemd_service() -> Tuple[bool, str]: + """Write systemd unit, enable and start xray service.""" + try: + with open(DEPLOY_XRAY_SERVICE, "w", encoding="utf-8") as f: + f.write(DEPLOY_SYSTEMD_UNIT) + except OSError as e: + return False, f"Failed to write service file: {e}" + + for cmd, label in [ + (["systemctl", "daemon-reload"], "daemon-reload"), + (["systemctl", "enable", "xray"], "enable"), + (["systemctl", "restart", "xray"], "start"), + ]: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode != 0: + return False, f"systemctl {label} failed: {result.stderr.strip()[:100]}" + except (OSError, subprocess.SubprocessError) as e: + return False, f"systemctl {label} error: {e}" + + time.sleep(1) + try: + result = subprocess.run( + ["systemctl", "is-active", "xray"], + capture_output=True, text=True, timeout=5, + ) + if result.stdout.strip() == "active": + return True, "Xray service running" + return False, f"Service status: {result.stdout.strip()}" + except (OSError, subprocess.SubprocessError) as e: + return False, f"Status check failed: {e}" + + +def deploy_run_pipeline(ds: "DeployState", print_fn) -> bool: + """Run the full deploy pipeline. Returns True on success.""" + + # Direct Mode: write config.json + systemd + steps = [ + ("Installing Xray binary", deploy_install_xray_system), + ("Writing server config", lambda: deploy_write_config(ds)), + ("Validating config", deploy_validate_config), + ("Setting up systemd service", deploy_systemd_service), + ] + + for label, step_fn in steps: + print_fn(f" [{label}]...") + ok, msg = step_fn() + if ok: + print_fn(f" OK: {msg}") + ds.steps_done.append(label) + else: + print_fn(f" FAILED: {msg}") + ds.error = f"{label}: {msg}" + return False + + # Generate client URIs + ds.client_uris = [] + try: + for i, parsed in enumerate(ds.parsed_configs): + tag = f"cfray-{parsed.get('protocol', 'vless')}-{i + 1}" + uri = build_client_uri_for_server(parsed, ds, tag, index=i) + ds.client_uris.append(uri) + except (KeyError, ValueError, TypeError) as e: + print_fn(f" FAILED to generate client URIs: {e}") + ds.error = f"URI generation: {e}" + return False + + return True + + +def deploy_save_results(ds: "DeployState") -> str: + """Save client URIs and deployment info to results/deploy_.txt.""" + try: + os.makedirs(RESULTS_DIR, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + path = _results_path(f"deploy_{ts}.txt") + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(f"# Xray Server Deploy - {time.strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write(f"# Server IP: {ds.server_ip}\n") + f.write(f"# Port: {ds.listen_port}\n") + f.write(f"# Config: {DEPLOY_XRAY_CONFIG}\n\n") + f.write("# Client URIs (paste into v2rayNG / Nekobox / Hiddify):\n\n") + for uri in ds.client_uris: + f.write(uri + "\n") + f.write(f"\n# Server config JSON:\n") + f.write(json.dumps(ds.server_config, indent=2) + "\n") + return path + except OSError as e: + _dbg(f"deploy_save_results failed: {e}") + return "" + + +# ─── Xray Server Deploy — Server Config Management ─────────────────────────── + + +def _read_server_config() -> Optional[dict]: + """Read and parse the xray server config.""" + try: + with open(DEPLOY_XRAY_CONFIG, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + except (OSError, ValueError): + pass + return None + + +def _write_server_config(config: dict) -> bool: + """Write xray server config atomically with backup. Returns True on success.""" + os.makedirs(DEPLOY_XRAY_CONFIG_DIR, exist_ok=True) + backup_dir = os.path.join(DEPLOY_XRAY_CONFIG_DIR, "backups") + os.makedirs(backup_dir, exist_ok=True) + # Validate JSON serialisable before touching disk + try: + data = json.dumps(config, indent=2) + except (TypeError, ValueError): + return False + # Backup existing config + if os.path.isfile(DEPLOY_XRAY_CONFIG): + ts = time.strftime("%Y%m%d_%H%M%S") + try: + shutil.copy2(DEPLOY_XRAY_CONFIG, os.path.join(backup_dir, f"config_{ts}.json")) + except OSError: + pass + # Atomic write: write to tmp then rename + tmp_path = DEPLOY_XRAY_CONFIG + ".tmp" + try: + _fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(_fd, "w", encoding="utf-8") as f: + f.write(data) + os.replace(tmp_path, DEPLOY_XRAY_CONFIG) + return True + except OSError: + try: + os.remove(tmp_path) + except OSError: + pass + return False + + +def _restart_xray_service() -> Tuple[bool, str]: + """Restart xray via systemctl. Returns (success, message).""" + if sys.platform in ("win32", "darwin"): + return False, "systemctl not available on this platform" + try: + result = subprocess.run( + ["systemctl", "restart", "xray"], + capture_output=True, text=True, timeout=15, + ) + if result.returncode != 0: + return False, f"restart failed: {result.stderr.strip()[:100]}" + except (OSError, subprocess.SubprocessError) as e: + return False, f"restart error: {e}" + time.sleep(1) + try: + result = subprocess.run( + ["systemctl", "is-active", "xray"], + capture_output=True, text=True, timeout=5, + ) + if result.stdout.strip() == "active": + return True, "Xray service running" + return False, f"Service status: {result.stdout.strip()}" + except (OSError, subprocess.SubprocessError) as e: + return False, f"Status check: {e}" + + +def _parse_inbound_summary(inbound: dict) -> dict: + """Extract readable summary from a server inbound config.""" + stream = inbound.get("streamSettings") or {} + if isinstance(stream, str): + try: + stream = json.loads(stream) + except (ValueError, TypeError): + stream = {} + if not isinstance(stream, dict): + stream = {} + clients = [] + settings = inbound.get("settings") or {} + if isinstance(settings, str): + try: + settings = json.loads(settings) + except (ValueError, TypeError): + settings = {} + if not isinstance(settings, dict): + settings = {} + if isinstance(settings.get("clients"), list): + clients = settings["clients"] + return { + "tag": inbound.get("remark") or inbound.get("tag", "?"), + "id": inbound.get("id"), + "protocol": inbound.get("protocol", "?"), + "port": inbound.get("port", "?"), + "transport": stream.get("network", "tcp"), + "security": stream.get("security", "none"), + "users": len(clients), + } + + +def _cm_build_client_uri(inbound: dict, uuid_val: str, server_ip: str) -> Optional[str]: + """Build a client URI from an existing inbound config + UUID. Returns None on failure.""" + try: + stream = inbound.get("streamSettings") or {} + if isinstance(stream, str): + stream = json.loads(stream) + if not isinstance(stream, dict): + stream = {} + protocol = inbound.get("protocol", "vless") + port = int(inbound.get("port", 443)) + transport = stream.get("network", "tcp") + security = stream.get("security", "none") + parsed: dict = { + "protocol": protocol, "address": server_ip, + "port": port, "uuid": uuid_val, + "type": transport, "security": security, "fp": "chrome", + } + # Transport paths + if transport == "ws": + ws = stream.get("wsSettings") or {} + parsed["path"] = ws.get("path", "/ws") + parsed["host"] = ws.get("headers", {}).get("Host", "") + elif transport in ("xhttp", "splithttp"): + parsed["type"] = "xhttp" + xh = stream.get("xhttpSettings") or stream.get("splithttpSettings") or {} + parsed["path"] = xh.get("path", "/xhttp") + elif transport == "grpc": + gs = stream.get("grpcSettings") or {} + parsed["serviceName"] = gs.get("serviceName", "grpc") + elif transport in ("h2", "http"): + hs = stream.get("httpSettings") or {} + parsed["path"] = hs.get("path", "/h2") + # Security: REALITY + sni = "" + if security == "reality": + rs = stream.get("realitySettings") or {} + snames = rs.get("serverNames") or [] + sni = snames[0] if snames else "" + parsed["sni"] = sni + sid_list = rs.get("shortIds") or [] + parsed["sid"] = sid_list[0] if sid_list else "" + # Derive public key from private key + priv = rs.get("privateKey", "") + if priv: + _xbin = xray_find_binary(None) + if _xbin: + try: + kw = {} + if sys.platform == "win32": + kw["creationflags"] = 0x08000000 + r = subprocess.run( + [_xbin, "x25519", "-i", priv], + capture_output=True, text=True, timeout=10, **kw, + ) + for line in r.stdout.strip().splitlines(): + if line.strip().lower().startswith("public key:"): + parsed["pbk"] = line.split(":", 1)[1].strip() + break + except (OSError, subprocess.SubprocessError): + pass + if protocol == "vless" and transport == "tcp": + parsed["flow"] = "xtls-rprx-vision" + elif security == "tls": + tls_s = stream.get("tlsSettings") or {} + sni = tls_s.get("serverName", "") + parsed["sni"] = sni + tag = f"cfray-{protocol}-{port}" + return _build_uri(parsed, sni, tag) + except (KeyError, ValueError, TypeError, IndexError): + return None + + +# ─── Xray Server Deploy — TUI Functions ────────────────────────────────────── + + +def _tui_deploy_detect_ip(ds: "DeployState"): + """Auto-detect server IP and prompt for override.""" + _w(f"\n {A.DIM}Detecting server IP...{A.RST}") + _fl() + ds.server_ip = deploy_detect_server_ip() + if ds.server_ip: + _w(f" {A.GRN}{ds.server_ip}{A.RST}\n") + else: + _w(f" {A.YEL}could not detect{A.RST}\n") + _w(f" {A.BOLD}Server IP [{ds.server_ip or 'enter manually'}]:{A.RST} ") + _fl() + try: + ip_input = input().strip() + except (EOFError, KeyboardInterrupt): + return False + if ip_input: + try: + ipaddress.ip_address(ip_input) + ds.server_ip = ip_input + except ValueError: + _w(f" {A.RED}Invalid IP address.{A.RST}\n") + _fl() + time.sleep(1) + return False + if not ds.server_ip: + _w(f" {A.RED}No server IP.{A.RST}\n") + _fl() + time.sleep(1) + return False + return True + + +def _tui_deploy_handle_security(parsed: dict, ds: "DeployState") -> bool: + """Handle REALITY key gen or TLS cert setup for an existing config.""" + sec = parsed.get("security", "none") + if sec == "reality": + _w(f"\n {A.DIM}Generating REALITY keys...{A.RST}") + _fl() + xray_bin = xray_find_binary() or "" + if not xray_bin: + xray_bin = xray_install() or "" + if not xray_bin: + _w(f" {A.RED}Need Xray to generate keys.{A.RST}\n") + _fl() + time.sleep(2) + return False + priv, pub = deploy_generate_reality_keys(xray_bin) + if not priv or not pub: + _w(f" {A.RED}Key generation failed.{A.RST}\n") + _fl() + time.sleep(2) + return False + ds.reality_private_key = priv + ds.reality_public_key = pub + ds.reality_short_id = deploy_generate_short_id() + parsed["pbk"] = pub + parsed["sid"] = ds.reality_short_id + _w(f" {A.GRN}OK{A.RST}\n") + elif sec == "tls": + _w(f"\n {A.BOLD}TLS Certificate:{A.RST}\n") + _w(f" {A.CYN}1{A.RST}. Auto-obtain via certbot\n") + _w(f" {A.CYN}2{A.RST}. Enter cert/key paths\n") + _w(f" Choice [1]: ") + _fl() + try: + cc = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + return False + ds.tls_domain = parsed.get("sni", "") or parsed.get("host", "") + if cc == "1" and not ds.tls_domain: + _w(f" {A.YEL}No domain found in config. Enter cert paths manually.{A.RST}\n") + cc = "2" + if cc == "1" and ds.tls_domain: + _w(f" {A.DIM}Running certbot for {ds.tls_domain}...{A.RST}\n") + _fl() + ok, cert, key = deploy_setup_certbot(ds.tls_domain) + if ok: + ds.tls_cert_path = cert + ds.tls_key_path = key + _w(f" {A.GRN}Certificate obtained!{A.RST}\n") + else: + _w(f" {A.RED}Certbot failed. Enter paths manually.{A.RST}\n") + cc = "2" + if cc == "2": + _w(f" {A.CYN}Certificate file path:{A.RST} ") + _fl() + try: + ds.tls_cert_path = input().strip() + except (EOFError, KeyboardInterrupt): + return False + _w(f" {A.CYN}Private key file path:{A.RST} ") + _fl() + try: + ds.tls_key_path = input().strip() + except (EOFError, KeyboardInterrupt): + return False + if not os.path.isfile(ds.tls_cert_path) or not os.path.isfile(ds.tls_key_path): + _w(f" {A.RED}Cert/key files not found.{A.RST}\n") + _fl() + time.sleep(1) + return False + return True + + +def _tui_deploy_fresh_wizard(ds: "DeployState") -> Optional["DeployState"]: + """Wizard for generating a fresh Xray server config (supports multiple configs).""" + if not _tui_deploy_detect_ip(ds): + return None + + ds.parsed_configs = [] + config_num = 0 + _reality_done = False + _tls_done = False + _saved_reality_sni = "" + _saved_tls_sni = "" + + while True: + if config_num > 0: + _w(f"\n {A.BOLD}{A.CYN}── Config #{config_num + 1} ──{A.RST}\n") + + # Protocol + _w(f"\n {A.BOLD}Protocol:{A.RST}\n") + _w(f" {A.CYN}1{A.RST}. VLESS {A.GRN}(recommended){A.RST}\n") + _w(f" {A.CYN}2{A.RST}. VMess\n") + _w(f" Choice [1]: ") + _fl() + try: + proto = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + break + protocol = "vmess" if proto == "2" else "vless" + + # Security + _w(f"\n {A.BOLD}Security:{A.RST}\n") + _w(f" {A.CYN}1{A.RST}. REALITY (no certs needed) {A.GRN}(recommended){A.RST}\n") + _w(f" {A.CYN}2{A.RST}. TLS (needs domain + certificate)\n") + _w(f" {A.CYN}3{A.RST}. None (no encryption)\n") + _w(f" Choice [1]: ") + _fl() + try: + sec_choice = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + break + security = {"1": "reality", "2": "tls", "3": "none"}.get(sec_choice, "reality") + + if security == "reality" and protocol == "vmess": + _w(f" {A.YEL}REALITY requires VLESS. Switching to VLESS.{A.RST}\n") + protocol = "vless" + + # Transport + _w(f"\n {A.BOLD}Transport:{A.RST}\n") + if security == "reality": + _w(f" {A.CYN}1{A.RST}. TCP (+ XTLS Vision) {A.GRN}(recommended for REALITY){A.RST}\n") + _w(f" {A.CYN}2{A.RST}. gRPC\n") + _w(f" {A.CYN}3{A.RST}. H2\n") + else: + _w(f" {A.CYN}1{A.RST}. TCP\n") + _w(f" {A.CYN}2{A.RST}. WebSocket {A.GRN}(CDN-compatible){A.RST}\n") + _w(f" {A.CYN}3{A.RST}. gRPC {A.GRN}(CDN-compatible){A.RST}\n") + _w(f" {A.CYN}4{A.RST}. H2\n") + _w(f" {A.CYN}5{A.RST}. XHTTP {A.GRN}(CDN-compatible){A.RST}\n") + _w(f" Choice [1]: ") + _fl() + try: + trans_choice = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + break + if security == "reality": + transport = {"1": "tcp", "2": "grpc", "3": "h2"}.get(trans_choice, "tcp") + else: + transport = {"1": "tcp", "2": "ws", "3": "grpc", "4": "h2", "5": "xhttp"}.get(trans_choice, "tcp") + + # Port + if config_num == 0: + _w(f"\n {A.BOLD}Port [443]:{A.RST} ") + _fl() + try: + port_input = input().strip() or "443" + except (EOFError, KeyboardInterrupt): + break + try: + port = int(port_input) + if not (1 <= port <= 65535): + port = 443 + except ValueError: + port = 443 + ds.listen_port = port + + # Check if port is free + if not deploy_check_port(port): + _w(f" {A.YEL}Warning: port {port} is already in use by another process{A.RST}\n") + _w(f" {A.CYN}Continue anyway? [y/N]:{A.RST} ") + _fl() + try: + _pc = input().strip().lower() + except (EOFError, KeyboardInterrupt): + break + if _pc not in ("y", "yes"): + break + else: + port = ds.listen_port + config_num + _w(f"\n {A.DIM}Port: {port}{A.RST}\n") + + # SNI / domain + sni = "" + if security == "reality": + if _saved_reality_sni and config_num > 0: + sni = _saved_reality_sni + _w(f"\n {A.DIM}REALITY dest: {sni} (reusing){A.RST}\n") + else: + _w(f"\n {A.BOLD}REALITY dest domain [www.google.com]:{A.RST} ") + _fl() + try: + sni = input().strip() or "www.google.com" + except (EOFError, KeyboardInterrupt): + break + _saved_reality_sni = sni + elif security == "tls": + if _saved_tls_sni and config_num > 0: + sni = _saved_tls_sni + _w(f"\n {A.DIM}TLS domain: {sni} (reusing){A.RST}\n") + else: + _w(f"\n {A.BOLD}Domain for TLS certificate:{A.RST} ") + _fl() + try: + sni = input().strip() + except (EOFError, KeyboardInterrupt): + break + if not sni: + _w(f" {A.RED}Domain required for TLS.{A.RST}\n") + _fl() + time.sleep(1) + break + _saved_tls_sni = sni + ds.tls_domain = sni + + # Generate UUID + uuid_val = deploy_generate_uuid() + _w(f"\n {A.DIM}Generated UUID: {uuid_val}{A.RST}\n") + + # Generate REALITY keys (once) + if security == "reality" and not _reality_done: + _w(f" {A.DIM}Generating REALITY keys...{A.RST}") + _fl() + xray_bin = xray_find_binary() or "" + if not xray_bin: + _w(f" {A.YEL}installing Xray first...{A.RST}") + _fl() + xray_bin = xray_install() or "" + if not xray_bin: + _w(f" {A.RED}Failed to install Xray.{A.RST}\n") + _fl() + time.sleep(2) + break + priv, pub = deploy_generate_reality_keys(xray_bin) + if not priv or not pub: + _w(f" {A.RED}Key generation failed.{A.RST}\n") + _fl() + time.sleep(2) + break + ds.reality_private_key = priv + ds.reality_public_key = pub + ds.reality_short_id = deploy_generate_short_id() + _w(f" {A.GRN}OK{A.RST}\n") + _reality_done = True + elif security == "reality": + _w(f" {A.DIM}Reusing REALITY keys{A.RST}\n") + + # Handle TLS certs (once) + if security == "tls" and not _tls_done: + _tmp_parsed = {"security": "tls", "sni": sni, "host": sni} + if not _tui_deploy_handle_security(_tmp_parsed, ds): + break + _tls_done = True + elif security == "tls": + _w(f" {A.DIM}Reusing TLS certificate{A.RST}\n") + + # Build this config + parsed = deploy_fresh_config(protocol, transport, security, port, uuid_val, sni, ds) + parsed["port"] = port + ds.parsed_configs.append(parsed) + config_num += 1 + + _w(f"\n {A.GRN}Config #{config_num} added: {protocol}/{transport}/{security} on port {port}{A.RST}\n") + _w(f"\n {A.CYN}Add another config? [y/N]:{A.RST} ") + _fl() + try: + again = input().strip().lower() + except (EOFError, KeyboardInterrupt): + break + if again not in ("y", "yes"): + break + + if not ds.parsed_configs: + return None + build_server_config(ds) + return ds + + +def _tui_deploy_from_uri(ds: "DeployState") -> Optional["DeployState"]: + """Deploy from an existing VLESS/VMess URI.""" + _w(f"\n {A.BOLD}Paste VLESS/VMess URI:{A.RST}\n ") + _fl() + try: + uri = input().strip() + except (EOFError, KeyboardInterrupt): + return None + + parsed = parse_vless_full(uri) or parse_vmess_full(uri) + if not parsed: + _w(f" {A.RED}Invalid VLESS/VMess URI.{A.RST}\n") + _fl() + time.sleep(1) + return None + + ds.source_uris = [uri] + ds.parsed_configs = [parsed] + + if not _tui_deploy_detect_ip(ds): + return None + + try: + ds.listen_port = int(parsed.get("port", 443)) + except (ValueError, TypeError): + ds.listen_port = 443 + if not (1 <= ds.listen_port <= 65535): + ds.listen_port = 443 + _w(f" {A.BOLD}Port [{ds.listen_port}]:{A.RST} ") + _fl() + try: + port_in = input().strip() + except (EOFError, KeyboardInterrupt): + return None + if port_in: + try: + pv = int(port_in) + if 1 <= pv <= 65535: + ds.listen_port = pv + except ValueError: + pass + + # Reject VMess + REALITY (not supported by Xray) + if parsed.get("protocol") == "vmess" and parsed.get("security") == "reality": + _w(f" {A.RED}VMess + REALITY is not supported. Use VLESS instead.{A.RST}\n") + _fl() + time.sleep(2) + return None + + if not _tui_deploy_handle_security(parsed, ds): + return None + + parsed["address"] = ds.server_ip + build_server_config(ds) + return ds + + +def _tui_deploy_from_file(ds: "DeployState") -> Optional["DeployState"]: + """Deploy from a file of URIs.""" + _w(f" {A.CYN}File path:{A.RST} ") + _fl() + try: + path = input().strip() + except (EOFError, KeyboardInterrupt): + return None + if not os.path.isfile(path): + _w(f" {A.RED}File not found.{A.RST}\n") + _fl() + time.sleep(1) + return None + + configs = load_input(path) + if not configs: + _w(f" {A.RED}No valid configs found.{A.RST}\n") + _fl() + time.sleep(1) + return None + + for c in configs: + if c.original_uri: + parsed = parse_vless_full(c.original_uri) or parse_vmess_full(c.original_uri) + if parsed: + ds.source_uris.append(c.original_uri) + ds.parsed_configs.append(parsed) + + if not ds.parsed_configs: + _w(f" {A.RED}No parseable VLESS/VMess URIs in file.{A.RST}\n") + _fl() + time.sleep(1) + return None + + _w(f" {A.GRN}Found {len(ds.parsed_configs)} config(s){A.RST}\n") + + if not _tui_deploy_detect_ip(ds): + return None + + try: + ds.listen_port = int(ds.parsed_configs[0].get("port", 443)) + except (ValueError, TypeError): + ds.listen_port = 443 + if not (1 <= ds.listen_port <= 65535): + ds.listen_port = 443 + _w(f" {A.BOLD}Port [{ds.listen_port}]:{A.RST} ") + _fl() + try: + port_in = input().strip() + except (EOFError, KeyboardInterrupt): + return None + if port_in: + try: + pv = int(port_in) + if 1 <= pv <= 65535: + ds.listen_port = pv + except ValueError: + pass + + # Filter out VMess + REALITY (not supported) -- keep source_uris in sync + paired = [(u, p) for u, p in zip(ds.source_uris, ds.parsed_configs) + if not (p.get("protocol") == "vmess" and p.get("security") == "reality")] + skipped = len(ds.parsed_configs) - len(paired) + if skipped: + _w(f" {A.YEL}Skipped {skipped} VMess+REALITY config(s) (not supported){A.RST}\n") + if not paired: + _w(f" {A.RED}No valid configs after filtering.{A.RST}\n") + _fl() + time.sleep(1) + return None + ds.source_uris = [u for u, _ in paired] + ds.parsed_configs = [p for _, p in paired] + + # Warn about mixed security types + sec_types = set(p.get("security", "none") for p in ds.parsed_configs) + if len(sec_types) > 1: + _w(f" {A.YEL}Warning: mixed security types ({', '.join(sec_types)}). " + f"Keys/certs configured for first config only.{A.RST}\n") + _fl() + + if not _tui_deploy_handle_security(ds.parsed_configs[0], ds): + return None + + for p in ds.parsed_configs: + p["address"] = ds.server_ip + + build_server_config(ds) + return ds + + +def tui_deploy_input() -> Optional["DeployState"]: + """Interactive wizard for server deployment. + Returns a configured DeployState or None. + """ + _w(A.SHOW) + _w(f"\n {A.BOLD}{A.CYN}Deploy Xray Server{A.RST}\n") + _w(f" {A.YEL}For:{A.RST} You have a Linux VPS and want to install xray on it (no tunnel).\n") + _w(f" {A.DIM}Installs xray, generates config, starts the service. Run this ON your server.{A.RST}\n\n") + + ok, err = deploy_check_prerequisites() + if not ok: + _w(f" {A.RED}ERROR: {err}{A.RST}\n") + _fl() + time.sleep(3) + return None + + ds = DeployState() + + ds.fresh_mode = True + return _tui_deploy_fresh_wizard(ds) + + +async def _tui_run_deploy(args, preloaded_uri: str = ""): + """Run the deploy flow inside TUI.""" + if preloaded_uri: + ds = DeployState() + parsed = parse_vless_full(preloaded_uri) or parse_vmess_full(preloaded_uri) + if parsed: + ds.source_uris = [preloaded_uri] + ds.parsed_configs = [parsed] + _w(A.SHOW) + _w(f"\n {A.BOLD}{A.CYN}Deploy Xray Server{A.RST}\n") + _w(f" {A.DIM}Deploying best config from xray test.{A.RST}\n") + ok, err = deploy_check_prerequisites() + if not ok: + _w(f" {A.RED}ERROR: {err}{A.RST}\n") + _fl() + time.sleep(3) + return + if not _tui_deploy_detect_ip(ds): + return + try: + ds.listen_port = int(parsed.get("port", 443)) + except (ValueError, TypeError): + ds.listen_port = 443 + if not (1 <= ds.listen_port <= 65535): + ds.listen_port = 443 + if not deploy_check_port(ds.listen_port): + _w(f" {A.YEL}Warning: port {ds.listen_port} is already in use{A.RST}\n") + _w(f" {A.CYN}Continue anyway? [y/N]:{A.RST} ") + _fl() + try: + _pc = input().strip().lower() + except (EOFError, KeyboardInterrupt): + return + if _pc not in ("y", "yes"): + return + if parsed.get("protocol") == "vmess" and parsed.get("security") == "reality": + _w(f" {A.RED}VMess + REALITY is not supported.{A.RST}\n") + _fl() + time.sleep(2) + return + if not _tui_deploy_handle_security(parsed, ds): + return + parsed["address"] = ds.server_ip + build_server_config(ds) + else: + _w(f" {A.RED}Failed to parse config URI.{A.RST}\n") + _fl() + time.sleep(2) + return + else: + ds = tui_deploy_input() + if ds is None: + return + + _w(A.SHOW) + _w(f"\n {A.BOLD}{A.CYN}Deploying Xray Server{A.RST}\n") + _w(f" {A.DIM}{'=' * 50}{A.RST}\n\n") + + def tui_print(msg): + _w(f"{msg}\n") + _fl() + + success = deploy_run_pipeline(ds, tui_print) + + if success: + _w(f"\n {A.GRN}{'=' * 50}{A.RST}\n") + _w(f" {A.BOLD}{A.GRN}Deploy successful!{A.RST}\n") + _w(f" {A.GRN}{'=' * 50}{A.RST}\n\n") + _w(f" {A.BOLD}Server:{A.RST} {ds.server_ip}:{ds.listen_port}\n") + _w(f" {A.BOLD}Config:{A.RST} {DEPLOY_XRAY_CONFIG}\n") + _w(f" {A.BOLD}Status:{A.RST} systemctl status xray\n\n") + + _w(f" {A.BOLD}{A.CYN}Client URIs (paste into v2rayNG / Nekobox / Hiddify):{A.RST}\n\n") + for uri in ds.client_uris: + _w(f" {A.GRN}{uri}{A.RST}\n\n") + + save_path = deploy_save_results(ds) + if save_path: + _w(f" {A.DIM}Saved to: {save_path}{A.RST}\n") + else: + _w(f" {A.RED}Could not save deploy results.{A.RST}\n") + else: + _w(f"\n {A.RED}Deploy failed: {ds.error}{A.RST}\n") + _w(f"\n {A.DIM}Press any key to continue...{A.RST}\n") + _fl() + _read_key_blocking() + return + + # Post-deploy interactive menu + while True: + _w(f"\n {A.CYN}[V]{A.RST} View configs/URIs ") + _w(f"{A.CYN}[M]{A.RST} Connection Manager ") + _w(f"{A.CYN}[Q]{A.RST} Back to menu\n") + _w(f" Choice: ") + _fl() + post_key = _read_key_blocking() + if isinstance(post_key, str): + post_key = post_key.lower() + if post_key in ("q", "esc", "ctrl-c", "b"): + break + elif post_key == "v": + _w(f"\n {A.BOLD}{A.CYN}Client URIs:{A.RST}\n\n") + for uri in ds.client_uris: + _w(f" {A.GRN}{uri}{A.RST}\n\n") + if save_path: + _w(f" {A.DIM}Saved to: {save_path}{A.RST}\n") + _fl() + elif post_key == "m": + await _tui_connection_manager(args) + break + + +# ─── Uninstall ───────────────────────────────────────────────────────────────── + + +def _uninstall_all() -> Tuple[bool, str]: + """Remove everything cfray installed on this system.""" + _out: list = [] + _had_errors = False + + def _log(msg: str): + _out.append(msg) + print(f" {msg}") + + def _log_err(msg: str): + nonlocal _had_errors + _had_errors = True + _out.append(msg) + print(f" ERROR: {msg}") + + if sys.platform in ("win32", "darwin"): + if os.path.isdir(XRAY_HOME): + shutil.rmtree(XRAY_HOME, ignore_errors=True) + if os.path.isdir(XRAY_HOME): + _log_err(f"Could not fully remove {XRAY_HOME}") + else: + _log(f"Removed {XRAY_HOME}") + else: + _log("Nothing to remove (no local cfray directory)") + return not _had_errors, "; ".join(_out) + + # --- 1. Stop xray service --- + for action in ["stop", "disable"]: + try: + subprocess.run(["systemctl", action, "xray"], + capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError): + pass + _log("Stopped and disabled xray service") + + # --- 2. Remove xray server files --- + _removed = [] + for path in [DEPLOY_XRAY_SERVICE, DEPLOY_XRAY_BIN]: + if os.path.isfile(path): + try: + os.remove(path) + _removed.append(path) + except OSError: + _log_err(f"Could not remove {path}") + for dpath in [DEPLOY_XRAY_CONFIG_DIR, DEPLOY_XRAY_SHARE]: + if os.path.isdir(dpath): + shutil.rmtree(dpath, ignore_errors=True) + if os.path.isdir(dpath): + _log_err(f"Could not fully remove {dpath}") + else: + _removed.append(dpath) + if _removed: + _log(f"Removed xray server: {', '.join(os.path.basename(p) for p in _removed)}") + elif not _had_errors: + _log("No xray server files found") + + # --- 3. Reload systemd --- + try: + subprocess.run(["systemctl", "daemon-reload"], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + pass + + # --- 4. Remove local client dir (~/.cfray/) --- + if os.path.isdir(XRAY_HOME): + shutil.rmtree(XRAY_HOME, ignore_errors=True) + if os.path.isdir(XRAY_HOME): + _log_err(f"Could not fully remove {XRAY_HOME}") + else: + _log(f"Removed {XRAY_HOME}") + else: + _log(f"No local directory at {XRAY_HOME}") + + return not _had_errors, "; ".join(_out) + + +# ─── Connection Manager (Direct JSON mode) ─────────────────────────────────── + + +async def _tui_connection_manager(args): + """TUI for managing xray server configs and connections.""" + if sys.platform in ("win32", "darwin"): + _w(A.SHOW) + _w(f"\n {A.RED}Connection Manager requires Linux (systemctl).{A.RST}\n") + _w(f" {A.DIM}Press any key...{A.RST}\n") + _fl() + _read_key_blocking() + return + + _cm_server_ip = "" # Cached; detected on first need + + while True: + # Direct JSON mode only + config = _read_server_config() + if config is not None: + ib_val = config.get("inbounds") + if not isinstance(ib_val, list): + ib_val = [] + config["inbounds"] = ib_val + inbounds = ib_val + else: + inbounds = [] + inbound_indices = [i for i, ib in enumerate(inbounds) if isinstance(ib, dict)] + + summaries = [_parse_inbound_summary(inbounds[i]) for i in inbound_indices] + + # Service status + xray_running = False + try: + r = subprocess.run(["systemctl", "is-active", "xray"], + capture_output=True, text=True, timeout=5) + xray_running = r.stdout.strip() == "active" + except (OSError, subprocess.SubprocessError): + pass + + _w(A.CLR + A.HOME + A.SHOW) + W, _ = term_size() + W = max(60, W - 2) + out = [] + out.append(f"{A.CYN}{'=' * (W + 2)}{A.RST}") + _cmhdr = f" {A.BOLD}{A.CYN}Connection Manager{A.RST}" + out.append(f"{A.CYN}|{A.RST}{_cmhdr}{' ' * max(0, W - _vl(_cmhdr))}{A.CYN}|{A.RST}") + out.append(f"{A.CYN}{'=' * (W + 2)}{A.RST}") + + # Service status + def bx(txt): + vlen = _vl(txt) + if vlen > W: + vis = 0 + i = 0 + while i < len(txt) and vis < W - 1: + if txt[i] == '\033' and i + 1 < len(txt) and txt[i + 1] == '[': + j = i + 2 + while j < len(txt) and txt[j] != 'm': + j += 1 + i = j + 1 + else: + vis += _char_width(txt[i]) + i += 1 + txt = txt[:i] + A.RST + "..." + vlen = _vl(txt) + pad = ' ' * max(0, W - vlen) + out.append(f"{A.CYN}|{A.RST}{txt}{pad}{A.CYN}|{A.RST}") + + xray_dot = f"{A.GRN}*{A.RST} running" if xray_running else f"{A.RED}*{A.RST} stopped" + bx(f" Xray Service: {xray_dot} {A.DIM}(system){A.RST}") + + out.append(f"{A.CYN}{'-' * (W + 2)}{A.RST}") + + # Inbounds + _has_inbounds = bool(summaries) + if not config: + bx(f" {A.DIM}No xray server config found.{A.RST}") + bx(f" {A.DIM}Use [D] Deploy to set up xray first.{A.RST}") + elif not summaries: + bx(f" {A.DIM}No inbounds configured.{A.RST}") + else: + bx(f" {A.BOLD}Server Inbounds ({len(summaries)}){A.RST}") + bx(f" {A.DIM}{'-' * (W - 4)}{A.RST}") + hdr = f" {'#':>2} {'Protocol':<10} {'Port':>6} {'Transport':<12} {'Security':<10} {'Users':>5}" + bx(f"{A.BOLD}{hdr}{A.RST}") + for i, s in enumerate(summaries[:20]): + line = f" {i+1:>2} {s['protocol']:<10} {s['port']:>6} {s['transport']:<12} {s['security']:<10} {s['users']:>5}" + bx(line) + + out.append(f"{A.CYN}{'-' * (W + 2)}{A.RST}") + + # Footer + parts = [] + if _has_inbounds: + parts.append(f"{A.CYN}[V]{A.RST} View") + parts.append(f"{A.CYN}[S]{A.RST} Show URIs") + parts.append(f"{A.CYN}[U]{A.RST} Add user") + parts.append(f"{A.CYN}[X]{A.RST} Remove") + parts.append(f"{A.CYN}[A]{A.RST} Add inbound") + parts.append(f"{A.CYN}[R]{A.RST} Restart xray") + parts.append(f"{A.CYN}[L]{A.RST} Logs") + parts.append(f"{A.CYN}[D]{A.RST} Uninstall") + parts.append(f"{A.CYN}[B]{A.RST} Back") + bx(f" {' '.join(parts)}") + out.append(f"{A.CYN}{'=' * (W + 2)}{A.RST}") + + _w("\n".join(out) + "\n") + _fl() + + key = _read_key_blocking() + if isinstance(key, str): + key = key.lower() + if key in ("b", "esc", "q", "ctrl-c"): + return + + if key == "r": + _w(f"\n {A.DIM}Restarting xray...{A.RST}\n") + _fl() + ok, msg = _restart_xray_service() + if ok: + _w(f" {A.GRN}{msg}{A.RST}\n") + else: + _w(f" {A.RED}{msg}{A.RST}\n") + _fl() + time.sleep(1.5) + continue + + if key == "l": + _w(A.CLR + A.HOME) + _w(f"\n {A.BOLD}{A.CYN}xray Logs (last 30 lines){A.RST}\n") + _w(f" {A.DIM}{'-' * 50}{A.RST}\n\n") + _fl() + try: + result = subprocess.run( + ["journalctl", "-u", "xray", "-n", "30", "--no-pager"], + capture_output=True, text=True, timeout=10, + ) + _w(result.stdout[:3000] if result.stdout else f" {A.DIM}(no logs){A.RST}\n") + except (OSError, subprocess.SubprocessError) as e: + _w(f" {A.RED}Failed to read logs: {e}{A.RST}\n") + _w(f"\n {A.DIM}Press any key to go back...{A.RST}\n") + _fl() + _read_key_blocking() + continue + + if key == "d": + _w(A.SHOW) + _w(f"\n {A.RED}{A.BOLD}Uninstall Xray completely?{A.RST}\n") + _w(f" {A.DIM}This will stop xray, remove the binary, config, and systemd service.{A.RST}\n") + _w(f"\n {A.RED}Type 'uninstall' to confirm:{A.RST} ") + _fl() + try: + confirm = input().strip().lower() + except (EOFError, KeyboardInterrupt): + continue + if confirm == "uninstall": + _w(f"\n {A.DIM}Uninstalling...{A.RST}\n") + _fl() + ok, msg = _uninstall_all() + if ok: + _w(f"\n {A.GRN}{msg}{A.RST}\n") + else: + _w(f"\n {A.RED}{msg}{A.RST}\n") + _w(f"\n {A.DIM}Press any key to go back...{A.RST}\n") + _fl() + _read_key_blocking() + return + else: + _w(f" {A.DIM}Cancelled.{A.RST}\n") + _fl() + time.sleep(1) + continue + + if key == "v" and summaries: + _w(A.SHOW) + which = _tui_prompt_text(f"View which inbound? [1-{len(summaries)}]:") + if which: + try: + sel = int(which) - 1 + if 0 <= sel < len(summaries): + ib_data = inbounds[inbound_indices[sel]] + _w(A.CLR + A.HOME) + _w(f"\n {A.BOLD}{A.CYN}Inbound #{sel+1}{A.RST}\n") + _w(f" {A.DIM}{'-' * 50}{A.RST}\n\n") + pretty = json.dumps(ib_data, indent=2, ensure_ascii=False) + _w(f"{pretty[:3000]}\n") + _w(f"\n {A.DIM}Press any key to go back...{A.RST}\n") + _fl() + _read_key_blocking() + except (ValueError, IndexError): + pass + continue + + if key == "s" and summaries: + _w(A.CLR + A.HOME) + _w(f"\n {A.BOLD}{A.CYN}All Client URIs{A.RST}\n") + _w(f" {A.DIM}{'-' * 50}{A.RST}\n\n") + if not _cm_server_ip: + _cm_server_ip = deploy_detect_server_ip() or "" + for i, s in enumerate(summaries): + real_idx = inbound_indices[i] + ib_data = inbounds[real_idx] + settings = ib_data.get("settings") or {} + clients = settings.get("clients") or [] + _w(f" {A.BOLD}Inbound #{i+1}{A.RST} ({s['protocol']}:{s['port']} {s['transport']}/{s['security']})\n") + for cl in clients: + _cl_uuid = cl.get("id", "") + if _cl_uuid: + _cl_uri = _cm_build_client_uri(ib_data, _cl_uuid, _cm_server_ip) + if _cl_uri: + _w(f" {A.GRN}{_cl_uri}{A.RST}\n") + _w("\n") + _w(f" {A.DIM}Press any key to go back...{A.RST}\n") + _fl() + _read_key_blocking() + continue + + if key == "u" and summaries: + _w(A.SHOW) + which = _tui_prompt_text(f"Add user to which inbound? [1-{len(summaries)}]:") + if which: + try: + sel = int(which) - 1 + if 0 <= sel < len(summaries): + new_uuid = deploy_generate_uuid() + _user_add_ok = False + real_idx = inbound_indices[sel] + ib = inbounds[real_idx] + settings = ib.get("settings") + if not isinstance(settings, dict): + settings = {} + ib["settings"] = settings + clients = settings.get("clients") + if not isinstance(clients, list): + clients = [] + settings["clients"] = clients + proto = ib.get("protocol", "vless") + new_client = {"id": new_uuid} + if proto == "vmess": + new_client["alterId"] = 0 + clients.append(new_client) + if _write_server_config(config): + ok, msg = _restart_xray_service() + _w(f"\n {A.GRN}User added: {new_uuid}{A.RST}\n") + if not ok: + _w(f" {A.YEL}Warning: {msg}{A.RST}\n") + _user_add_ok = True + else: + clients.pop() + _w(f"\n {A.RED}Failed to write config (run as root?){A.RST}\n") + if _user_add_ok: + if not _cm_server_ip: + _cm_server_ip = deploy_detect_server_ip() or "" + _u_uri = _cm_build_client_uri(ib, new_uuid, _cm_server_ip) + if _u_uri: + _w(f"\n {A.BOLD}{A.CYN}Client URI:{A.RST}\n") + _w(f" {A.GRN}{_u_uri}{A.RST}\n") + _w(f"\n {A.DIM}Press any key to continue...{A.RST}\n") + _fl() + _wait_any_key() + except (ValueError, IndexError): + pass + continue + + if key == "x" and summaries: + _w(A.SHOW) + which = _tui_prompt_text(f"Remove which inbound? [1-{len(summaries)}]:") + if which: + try: + sel = int(which) - 1 + if 0 <= sel < len(summaries): + s = summaries[sel] + _w(f" {A.YEL}Remove {s['protocol']}:{s['port']}? [y/N]:{A.RST} ") + _fl() + try: + confirm = input().strip().lower() + except (EOFError, KeyboardInterrupt): + confirm = "" + if confirm in ("y", "yes"): + real_idx = inbound_indices[sel] + removed = inbounds[real_idx] + inbounds.pop(real_idx) + if _write_server_config(config): + ok, msg = _restart_xray_service() + _w(f" {A.GRN}Inbound removed.{A.RST}\n") + if not ok: + _w(f" {A.YEL}Warning: {msg}{A.RST}\n") + else: + # Restore in-memory state on write failure + inbounds.insert(real_idx, removed) + _w(f" {A.RED}Failed to write config (run as root?){A.RST}\n") + _fl() + time.sleep(1.5) + except (ValueError, IndexError): + pass + continue + + if key == "a": + # Add inbound wizard + _w(A.CLR + A.HOME + A.SHOW) + _w(f"\n {A.BOLD}{A.CYN}Add New Inbound{A.RST}\n") + _w(f" {A.DIM}{'-' * 40}{A.RST}\n\n") + _w(f" {A.CYN}1{A.RST}. VLESS\n") + _w(f" {A.CYN}2{A.RST}. VMess\n") + _w(f"\n Protocol [1]: ") + _fl() + try: + proto_ch = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + continue + protocol = "vmess" if proto_ch == "2" else "vless" + + _w(f" Port [443]: ") + _fl() + try: + port_str = input().strip() or "443" + new_port = int(port_str) + if not (1 <= new_port <= 65535): + _w(f" {A.YEL}Invalid port, using 443{A.RST}\n") + new_port = 443 + except (EOFError, KeyboardInterrupt): + continue + except ValueError: + _w(f" {A.YEL}Invalid port, using 443{A.RST}\n") + new_port = 443 + # Check for port conflicts -- our own inbounds + used_ports = {int(ib.get("port", 0)) for ib in inbounds if isinstance(ib, dict) and ib.get("port")} + if new_port in used_ports: + _w(f" {A.YEL}Warning: port {new_port} already used by another inbound{A.RST}\n") + _w(f" {A.CYN}Continue anyway? [y/N]:{A.RST} ") + _fl() + try: + _pc = input().strip().lower() + except (EOFError, KeyboardInterrupt): + continue + if _pc not in ("y", "yes"): + continue + elif not deploy_check_port(new_port): + _w(f" {A.YEL}Warning: port {new_port} is already in use by another process{A.RST}\n") + _w(f" {A.CYN}Continue anyway? [y/N]:{A.RST} ") + _fl() + try: + _pc = input().strip().lower() + except (EOFError, KeyboardInterrupt): + continue + if _pc not in ("y", "yes"): + continue + + _w(f"\n {A.CYN}1{A.RST}. TCP\n") + _w(f" {A.CYN}2{A.RST}. WebSocket (ws)\n") + _w(f" {A.CYN}3{A.RST}. XHTTP (xhttp)\n") + _w(f" {A.CYN}4{A.RST}. gRPC\n") + _w(f" {A.CYN}5{A.RST}. HTTP/2 (h2)\n") + _w(f"\n Transport [1]: ") + _fl() + try: + tr_ch = input().strip() or "1" + except (EOFError, KeyboardInterrupt): + continue + tr_map = {"1": "tcp", "2": "ws", "3": "xhttp", "4": "grpc", "5": "h2"} + transport = tr_map.get(tr_ch, "tcp") + + _w(f"\n {A.CYN}1{A.RST}. REALITY\n") + _w(f" {A.CYN}2{A.RST}. TLS\n") + _w(f" {A.CYN}3{A.RST}. None\n") + _w(f"\n Security [3]: ") + _fl() + try: + sec_ch = input().strip() or "3" + except (EOFError, KeyboardInterrupt): + continue + sec_map = {"1": "reality", "2": "tls", "3": "none"} + security = sec_map.get(sec_ch, "none") + + # REALITY needs x25519 keys + _reality_priv = _reality_pub = _reality_sid = _rsni = "" + _tls_cert = _tls_key = "" + if security == "reality": + _xbin = xray_find_binary(None) + if not _xbin: + _w(f" {A.RED}REALITY requires xray binary for key generation.{A.RST}\n") + _w(f" {A.DIM}Falling back to no security. Use Deploy for REALITY.{A.RST}\n") + _fl() + time.sleep(1.5) + security = "none" + else: + _reality_priv, _reality_pub = deploy_generate_reality_keys(_xbin) + if not _reality_priv: + _w(f" {A.RED}Key generation failed. Falling back to none.{A.RST}\n") + _fl() + time.sleep(1.5) + security = "none" + else: + _reality_sid = deploy_generate_short_id() + _w(f" {A.DIM}SNI for REALITY [www.google.com]:{A.RST} ") + _fl() + try: + _rsni = input().strip() or "www.google.com" + except (EOFError, KeyboardInterrupt): + _rsni = "www.google.com" + elif security == "tls": + _w(f" {A.DIM}TLS cert path [/usr/local/etc/xray/cert.pem]:{A.RST} ") + _fl() + try: + _tls_cert = input().strip() or "/usr/local/etc/xray/cert.pem" + except (EOFError, KeyboardInterrupt): + _tls_cert = "/usr/local/etc/xray/cert.pem" + _w(f" {A.DIM}TLS key path [/usr/local/etc/xray/key.pem]:{A.RST} ") + _fl() + try: + _tls_key = input().strip() or "/usr/local/etc/xray/key.pem" + except (EOFError, KeyboardInterrupt): + _tls_key = "/usr/local/etc/xray/key.pem" + + new_uuid = deploy_generate_uuid() + _w(f"\n {A.DIM}Generated UUID: {new_uuid}{A.RST}\n") + + # Build inbound manually (use uuid4 suffix for unique tag) + _tag_id = deploy_generate_uuid()[:8] + new_inbound: dict = { + "tag": f"inbound-{_tag_id}", + "port": new_port, + "listen": "::", + "protocol": protocol, + "settings": {}, + "streamSettings": {"network": transport, "security": security}, + "sniffing": {"enabled": True, "destOverride": ["http", "tls", "quic"]}, + } + + # Client + client_entry: dict = {"id": new_uuid} + if protocol == "vmess": + client_entry["alterId"] = 0 + new_inbound["settings"] = {"clients": [client_entry]} + else: + new_inbound["settings"] = { + "clients": [client_entry], + "decryption": "none", + } + + # Transport settings + stream = new_inbound["streamSettings"] + if transport == "ws": + stream["wsSettings"] = {"path": "/ws"} + elif transport in ("xhttp", "splithttp"): + stream["network"] = "xhttp" + stream["xhttpSettings"] = {"path": "/xhttp"} + elif transport == "grpc": + stream["grpcSettings"] = {"serviceName": "grpc"} + elif transport in ("h2", "http"): + stream["httpSettings"] = {"host": [], "path": "/h2"} + + # Security settings + if security == "reality" and _reality_priv: + stream["realitySettings"] = { + "show": False, + "dest": f"{_rsni}:443", + "xver": 0, + "serverNames": [_rsni], + "privateKey": _reality_priv, + "shortIds": [_reality_sid], + } + # VLESS+REALITY+TCP needs flow + if protocol == "vless" and transport == "tcp": + client_entry["flow"] = "xtls-rprx-vision" + _w(f" {A.DIM}Public key: {_reality_pub}{A.RST}\n") + _w(f" {A.DIM}Short ID: {_reality_sid}{A.RST}\n") + elif security == "tls": + stream["tlsSettings"] = { + "certificates": [{ + "certificateFile": _tls_cert, + "keyFile": _tls_key, + }], + } + + if config is None: + config = { + "log": {"loglevel": "warning"}, + "inbounds": [], + "outbounds": [ + {"tag": "direct", "protocol": "freedom"}, + {"tag": "block", "protocol": "blackhole"}, + ], + "routing": { + "domainStrategy": "AsIs", + "rules": [{"type": "field", "ip": ["geoip:private"], "outboundTag": "block"}], + }, + } + inbounds = config["inbounds"] + + _add_ok = False + inbounds.append(new_inbound) + if _write_server_config(config): + ok, msg = _restart_xray_service() + _w(f"\n {A.GRN}Inbound added: {protocol}:{new_port} ({transport}/{security}){A.RST}\n") + if not ok: + _w(f" {A.YEL}Warning: {msg}{A.RST}\n") + _add_ok = True + else: + inbounds.pop() + _w(f"\n {A.RED}Failed to write config (run as root?){A.RST}\n") + + # Generate and display client URI after successful add + if _add_ok: + if not _cm_server_ip: + _cm_server_ip = deploy_detect_server_ip() or "" + _uri_sni = _rsni or "" + _uri_parsed = { + "protocol": protocol, + "address": _cm_server_ip, + "port": new_port, + "uuid": new_uuid, + "type": transport, + "security": security, + "fp": "chrome", + } + if transport == "ws": + _uri_parsed["path"] = "/ws" + _uri_parsed["host"] = _uri_sni + elif transport in ("xhttp", "splithttp"): + _uri_parsed["type"] = "xhttp" + _uri_parsed["path"] = "/xhttp" + elif transport == "grpc": + _uri_parsed["serviceName"] = "grpc" + elif transport in ("h2", "http"): + _uri_parsed["path"] = "/h2" + if security == "reality" and _reality_pub: + _uri_parsed["pbk"] = _reality_pub + _uri_parsed["sid"] = _reality_sid + _uri_parsed["sni"] = _rsni + if protocol == "vless" and transport == "tcp": + _uri_parsed["flow"] = "xtls-rprx-vision" + elif security == "tls": + _uri_parsed["sni"] = _uri_sni + _uri_tag = f"cfray-{protocol}-{new_port}" + try: + _client_uri = _build_uri(_uri_parsed, _uri_sni, _uri_tag) + _w(f"\n {A.BOLD}{A.CYN}Client URI:{A.RST}\n") + _w(f" {A.GRN}{_client_uri}{A.RST}\n") + except (KeyError, ValueError, TypeError) as _uri_err: + _w(f" {A.DIM}(Could not generate client URI: {_uri_err}){A.RST}\n") + + _w(f"\n {A.DIM}Press any key to continue...{A.RST}\n") + _fl() + _wait_any_key() + continue + + +# ─── End Xray Server Deploy ────────────────────────────────────────────── + + +# ─── Worker Proxy ───────────────────────────────────────────────────────────── + + +def _worker_proxy_generate_script(origin_host: str, origin_port: int, + origin_security: str = "tls") -> str: + """Generate CF Worker script to proxy WS to an origin behind CF CDN. + + Unlike _cdn_generate_worker_script (which targets a raw IP you own), + this targets an existing CF-backed host domain. The Worker rewrites + the Host header so CF routes the internal fetch to the real origin. + """ + scheme = "https" if origin_security in ("tls", "reality") else "http" + port_part = ("" if (scheme == "https" and origin_port == 443) + or (scheme == "http" and origin_port == 80) + else f":{origin_port}") + return f"""\ +// CFray Worker Proxy — route ANY SNI to origin +// Deploy: dash.cloudflare.com → Workers & Pages → Create → Deploy +// Free tier: 100K requests/day + +export default {{ + async fetch(request) {{ + const url = new URL(request.url); + const origin = "{scheme}://{origin_host}{port_part}" + url.pathname; + const headers = new Headers(request.headers); + headers.set("Host", "{origin_host}"); + return fetch(origin, {{ + method: request.method, + headers: headers, + }}); + }} +}};""" + + +async def _tui_worker_proxy(args): + """Worker Proxy — paste any VLESS URI, deploy a CF Worker, run pipeline + with ALL CF SNIs enabled. + + CF enforces zone matching (SNI must match Host domain's zone), so a + random VLESS config only works with the original SNI. A CF Worker + sits in its own zone (*.workers.dev); the Worker rewrites Host to the + origin domain and proxies internally. Result: every CF SNI works. + """ + enable_ansi() + _w(A.CLR + A.HOME + A.SHOW) + cols, _ = term_size() + W = cols - 2 + + _w(f"\n{A.CYN}{'=' * (W + 2)}{A.RST}\n") + _w(f"{A.CYN}|{A.RST} {A.BOLD}{A.WHT}Worker Proxy -- Fresh SNI for Any VLESS Config{A.RST}" + + " " * max(0, W - 50) + f"{A.CYN}|{A.RST}\n") + _w(f"{A.CYN}{'=' * (W + 2)}{A.RST}\n\n") + + _w(f" {A.DIM}If the original domain's SNI is blocked by DPI, a CF Worker gives{A.RST}\n") + _w(f" {A.DIM}you a fresh *.workers.dev SNI. The Worker proxies to the original{A.RST}\n") + _w(f" {A.DIM}server, so your configs work with a different (unblocked) SNI.{A.RST}\n\n") + + # -- Step 1: Paste VLESS URI -- + _restore_console_input() + _w(f" {A.BOLD}{A.CYN}[1/3]{A.RST} {A.BOLD}Paste your VLESS config URI:{A.RST}\n") + _w(f" {A.DIM}(a full vless://... URI){A.RST}\n ") + _fl() + try: + uri = input().strip() + except (EOFError, KeyboardInterrupt): + return + if not uri: + _w(f"\n {A.RED}Cancelled.{A.RST}\n") + time.sleep(1) + return + + parsed = parse_vless_full(uri) + if not parsed: + _w(f"\n {A.RED}Invalid VLESS URI.{A.RST}\n") + _w(f" {A.DIM}Press any key...{A.RST}\n") + _fl() + _read_key_blocking() + return + + # Must be WS transport + if parsed.get("type") not in ("ws", "websocket"): + _w(f"\n {A.RED}Only WebSocket (ws) transport is supported for Worker proxy.{A.RST}\n") + _w(f" {A.DIM}Your config uses: {parsed.get('type', 'unknown')}{A.RST}\n") + _w(f" {A.DIM}Press any key...{A.RST}\n") + _fl() + _read_key_blocking() + return + + # Extract origin info — Host header is what CF uses for internal routing + origin_host = parsed.get("host") or parsed.get("sni") or parsed.get("address", "") + origin_port = parsed.get("port", 443) + ws_path = parsed.get("path", "/") + uuid_val = parsed.get("uuid", "") + security = parsed.get("security", "tls") + + _w(f"\n {A.GRN}Protocol: VLESS | Transport: WS | Security: {security}{A.RST}\n") + _w(f" {A.GRN}Origin host: {origin_host}:{origin_port} | Path: {ws_path}{A.RST}\n") + _w(f" {A.GRN}UUID: {uuid_val[:8]}...{A.RST}\n\n") + + # -- Step 2: Generate Worker script -- + _w(f" {A.BOLD}{A.CYN}[2/3]{A.RST} {A.BOLD}Worker script generated:{A.RST}\n\n") + script = _worker_proxy_generate_script(origin_host, origin_port, security) + _w(f" {A.DIM}{'-' * (W - 2)}{A.RST}\n") + for line in script.split("\n"): + _w(f" {A.WHT}{line}{A.RST}\n") + _w(f" {A.DIM}{'-' * (W - 2)}{A.RST}\n\n") + + _w(f" {A.BOLD}Deploy instructions:{A.RST}\n\n") + _w(f" {A.WHT}1.{A.RST} Go to {A.CYN}dash.cloudflare.com{A.RST} -> Workers & Pages -> Create\n") + _w(f" {A.WHT}2.{A.RST} Click {A.WHT}\"Create Worker\"{A.RST}, name it anything\n") + _w(f" {A.WHT}3.{A.RST} Click {A.WHT}\"Deploy\"{A.RST}, then {A.WHT}\"Edit Code\"{A.RST}\n") + _w(f" {A.WHT}4.{A.RST} Delete all default code, paste the script above\n") + _w(f" {A.WHT}5.{A.RST} Click {A.WHT}\"Deploy\"{A.RST} again\n") + _w(f" {A.WHT}6.{A.RST} Copy your Worker URL (e.g. {A.GRN}my-proxy.username.workers.dev{A.RST})\n\n") + + # -- Step 3: Get Worker URL -- + _flush_stdin() # drain stale bytes from multi-line URI paste + _restore_console_input() # re-enable line editing (arrow keys, backspace) + _w(f" {A.BOLD}{A.CYN}[3/3]{A.RST} {A.YEL}Enter your Worker URL when deployed{A.RST} (or Enter to skip): ") + _fl() + try: + worker_url = input().strip() + except (EOFError, KeyboardInterrupt): + return + + if not worker_url: + _w(f"\n {A.DIM}Skipped. Deploy the Worker first, then come back.{A.RST}\n") + _w(f" {A.DIM}Press any key...{A.RST}\n") + _fl() + _read_key_blocking() + return + + # Clean up URL — strip protocol prefix and stale paste garbage + worker_url = worker_url.replace("https://", "").replace("http://", "").rstrip("/") + # Multi-line paste can leave stale chars (e.g. "6" from "#polaris\n6") + # prefixed to the URL. Strip leading non-letter chars before the domain. + _m = re.search(r'[a-zA-Z]', worker_url) + if _m and _m.start() > 0 and ".workers.dev" in worker_url: + worker_url = worker_url[_m.start():] + _w(f"\n {A.GRN}Worker URL: {worker_url}{A.RST}\n") + + # Build new VLESS URI pointing through the Worker + new_parsed = dict(parsed) + new_parsed["address"] = worker_url + new_parsed["host"] = worker_url + new_parsed["sni"] = worker_url + new_parsed["port"] = 443 + new_parsed["security"] = "tls" + new_uri = build_vless_uri(new_parsed, worker_url, "Worker-Proxy") + + _w(f"\n {A.BOLD}New config URI:{A.RST}\n") + _w(f" {A.GRN}{new_uri}{A.RST}\n\n") + + _w(f" {A.BOLD}{A.CYN}How it works:{A.RST}\n") + _w(f" {A.DIM}Client -> any CF IP (SNI={worker_url}) -> CF routes to Worker{A.RST}\n") + _w(f" {A.DIM}Worker -> Host={origin_host} -> CF routes to original server{A.RST}\n") + _w(f" {A.DIM}Result: fresh *.workers.dev SNI instead of original domain!{A.RST}\n\n") + + # Offer pipeline test + _w(f" {A.YEL}Run pipeline test with ALL SNIs?{A.RST} [Y/n]: ") + _fl() + try: + ans = input().strip().lower() + except (EOFError, KeyboardInterrupt): + ans = "n" + + if ans in ("", "y", "yes"): + re_parsed = parse_vless_full(new_uri) + if re_parsed: + # CF zone matching applies to Workers too — only the Worker URL + # works as SNI (it's in the workers.dev zone). Other CF domains + # (discord.com, etc.) return 403 because Host is cross-zone. + # The Worker gives you a fresh random *.workers.dev SNI instead + # of the original (possibly blocked) domain. + pcfg = PipelineConfig( + uri=new_uri, parsed=re_parsed, + sni_pool=[], + frag_preset="all", + transport_variants=[], + max_expansion=1500, + ) + xray_bin = xray_find_binary(getattr(args, "xray_bin", None)) + if not xray_bin: + _w(f" {A.YEL}Xray not found. Installing...{A.RST}\n") + _fl() + xray_bin = xray_install() + if xray_bin: + xst = XrayTestState() + xdash = await _run_pipeline_core(xst, pcfg, xray_bin) + await _post_pipeline_results(xst, xdash, args) + return # interactive loop handles exit + else: + _w(f" {A.RED}Could not find/install xray-core{A.RST}\n") + else: + _w(f" {A.RED}Failed to parse generated URI{A.RST}\n") + + _w(f"\n {A.DIM}Press any key to go back...{A.RST}\n") + _fl() + _read_key_blocking() + + +# ─── End Worker Proxy ───────────────────────────────────────────────────────── + + class Dashboard: def __init__(self, st: State): self.st = st @@ -2342,7 +8060,7 @@ class Dashboard: lines.append(f" {A.DIM}'-' means not tested yet (only top IPs get speed tested){A.RST}") lines.append(f" {A.CYN}{'=' * W}{A.RST}") lines.append(f" {A.BOLD}{A.WHT} Made By Sam - SamNet Technologies{A.RST}") - lines.append(f" {A.DIM} https://github.com/SamNet-dev/cfray{A.RST}") + lines.append(f" {A.DIM} https://git.samnet.dev/SamNet-dev/cfray{A.RST}") lines.append(f" {A.CYN}{'=' * W}{A.RST}") lines.append(f" {A.DIM}Press any key to go back{A.RST}") @@ -2572,13 +8290,15 @@ async def run_scan(st: State, workers: int, speed_workers: int, timeout: float, calc_scores(st) -async def run_tui(args): +async def run_tui(args, deploy_mode=False): """TUI mode: interactive startup + dashboard.""" enable_ansi() # Determine initial input source from CLI args input_method = None # "file", "sub", or "template" input_value = None + if deploy_mode: + input_method, input_value = "deploy", "" if getattr(args, "sub", None): input_method, input_value = "sub", args.sub elif getattr(args, "template", None): @@ -2602,6 +8322,42 @@ async def run_tui(args): return input_method, input_value = pick + if input_method == "pipeline": + await _tui_run_pipeline(args, cli_uri=input_value or "") + if interactive: + input_method = None + input_value = None + continue + else: + return + + if input_method == "deploy": + await _tui_run_deploy(args) + if interactive: + input_method = None + input_value = None + continue + else: + return + + if input_method == "worker_proxy": + await _tui_worker_proxy(args) + if interactive: + input_method = None + input_value = None + continue + else: + return + + if input_method == "connection_manager": + await _tui_connection_manager(args) + if interactive: + input_method = None + input_value = None + continue + else: + return + if input_method == "find_clean": result = await tui_run_clean_finder() if result is None: @@ -3080,11 +8836,48 @@ Examples: p.add_argument("--clean-mode", choices=["quick", "normal", "full", "mega"], default="normal", help="Clean IP scan scope (quick=~4K, normal=~12K, full=~1.5M, mega=~3M multi-port)") p.add_argument("--subnets", help="Custom subnets file or comma-separated CIDRs") + # Xray Proxy Testing + p.add_argument("--xray", metavar="URI", + help="VLESS/VMess URI to test through Xray-core proxy") + p.add_argument("--xray-frag", metavar="PRESET", + choices=["none", "light", "medium", "heavy", "all"], default="all", + help="Fragment preset (default: all)") + p.add_argument("--xray-bin", metavar="PATH", + help="Path to xray binary (auto-detect if not set)") + p.add_argument("--xray-install", action="store_true", + help="Download and install xray-core to ~/.cfray/bin/") + p.add_argument("--xray-keep", type=int, default=10, + help="Export top N xray results (default: 10)") + # Deploy + p.add_argument("--deploy", nargs="?", const="interactive", metavar="URI_OR_FILE", + help="Deploy Xray server on this Linux VPS") + p.add_argument("--deploy-port", type=int, default=443) + p.add_argument("--deploy-protocol", choices=["vless", "vmess"], default="vless") + p.add_argument("--deploy-transport", choices=["tcp", "ws", "grpc", "h2"], default="tcp") + p.add_argument("--deploy-security", choices=["reality", "tls", "none"], default="reality") + p.add_argument("--deploy-sni", metavar="DOMAIN") + p.add_argument("--deploy-cert", metavar="PATH") + p.add_argument("--deploy-key", metavar="PATH") + p.add_argument("--deploy-ip", metavar="IP") + p.add_argument("--uninstall", action="store_true", + help="Remove everything cfray installed") args = p.parse_args() args._mode_set = any(a == "-m" or a.startswith("--mode") for a in sys.argv) try: + if getattr(args, "xray_install", False): + path = xray_install() + if path: + print(f"Installed: {path}") + return + if getattr(args, "uninstall", False): + ok, msg = _uninstall_all() + print(msg) + return + if getattr(args, "deploy", None): + asyncio.run(run_tui(args, deploy_mode=True)) + return if getattr(args, "find_clean", False) and args.no_tui: asyncio.run(run_headless_clean(args)) elif args.no_tui: