Files
cfray/scanner.py

8898 lines
338 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
#
# ┌─────────────────────────────────────────────────────────────────┐
# │ │
# │ ⚡ CF CONFIG SCANNER v1.1 │
# │ │
# │ Test VLESS/VMess proxy configs for latency + download speed │
# │ │
# │ • Latency test (TCP + TLS) all IPs in seconds │
# │ • Download speed test via progressive funnel │
# │ • Live TUI dashboard with real-time results │
# │ • Smart rate limiting with CDN fallback │
# │ • 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 │
# │ │
# │ Repo: https://git.samnet.dev/SamNet-dev/cfray │
# │ │
# └─────────────────────────────────────────────────────────────────┘
#
# Usage:
# python3 scanner.py Interactive TUI
# python3 scanner.py -i configs.txt Normal mode
# python3 scanner.py --sub https://example.com/sub Fetch from subscription
# python3 scanner.py --template "vless://..." -i addrs.json Generate + test
# python3 scanner.py --find-clean --no-tui --clean-mode mega Clean IP scan
#
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.1"
SPEED_HOST = "speed.cloudflare.com"
SPEED_PATH = "/__down"
DEBUG_LOG = os.path.join("results", "debug.log")
LOG_MAX_BYTES = 5 * 1024 * 1024
LATENCY_WORKERS = 50
SPEED_WORKERS = 10
LATENCY_TIMEOUT = 5.0
SPEED_TIMEOUT = 30.0
CDN_FALLBACK = ("cloudflaremirrors.com", "/archlinux/iso/latest/archlinux-x86_64.iso")
# Cloudflare published IPv4 ranges (https://www.cloudflare.com/ips-v4/)
CF_SUBNETS = [
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22",
"103.31.4.0/22", "141.101.64.0/18", "108.162.192.0/18",
"190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22",
"198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
"104.24.0.0/14", "172.64.0.0/13",
]
CF_HTTPS_PORTS = [443, 8443, 2053, 2083, 2087, 2096]
CLEAN_MODES = {
"quick": {"label": "Quick", "sample": 1, "workers": 500, "validate": False,
"ports": [443], "desc": "1 random IP per /24 (~4K IPs, ~30s)"},
"normal": {"label": "Normal", "sample": 3, "workers": 500, "validate": True,
"ports": [443], "desc": "3 IPs per /24 + CF verify (~12K IPs, ~2 min)"},
"full": {"label": "Full", "sample": 0, "workers": 1000, "validate": True,
"ports": [443], "desc": "All IPs + CF verify (~1.5M IPs, 20+ min)"},
"mega": {"label": "Mega", "sample": 0, "workers": 1500, "validate": True,
"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",
"desc": "Latency sort -> 1MB top 100 -> 5MB top 20",
"dynamic": True,
"latency_cut": 50,
"round_sizes": [1_000_000, 5_000_000],
"round_pcts": [100, 20],
"round_min": [50, 10],
"round_max": [100, 20],
"data": "~200 MB",
"time": "~2-3 min",
},
"normal": {
"label": "Normal",
"desc": "Latency sort -> 1MB top 200 -> 5MB top 50 -> 20MB top 20",
"dynamic": True,
"latency_cut": 40,
"round_sizes": [1_000_000, 5_000_000, 20_000_000],
"round_pcts": [100, 25, 10],
"round_min": [50, 20, 10],
"round_max": [200, 50, 20],
"data": "~850 MB",
"time": "~5-10 min",
},
"thorough": {
"label": "Thorough",
"desc": "Deep funnel: 5MB / 25MB / 50MB",
"dynamic": True,
"latency_cut": 15,
"round_sizes": [5_000_000, 25_000_000, 50_000_000],
"round_pcts": [100, 25, 10],
"round_min": [0, 30, 15],
"round_max": [0, 150, 50],
"data": "~5-10 GB",
"time": "~20-45 min",
},
}
class A:
RST = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
ITAL = "\033[3m"
ULINE = "\033[4m"
RED = "\033[31m"
GRN = "\033[32m"
YEL = "\033[33m"
BLU = "\033[34m"
MAG = "\033[35m"
CYN = "\033[36m"
WHT = "\033[97m"
BGBL = "\033[44m"
BGDG = "\033[100m"
HOME = "\033[H"
CLR = "\033[H\033[J"
EL = "\033[2K"
HIDE = "\033[?25l"
SHOW = "\033[?25h"
_ansi_re = re.compile(r"\033\[[^m]*m")
def _dbg(msg: str):
"""Append a debug line to results/debug.log with rotation."""
try:
os.makedirs("results", exist_ok=True)
if os.path.exists(DEBUG_LOG):
try:
sz = os.path.getsize(DEBUG_LOG)
if sz > LOG_MAX_BYTES:
bak = DEBUG_LOG + ".1"
if os.path.exists(bak):
os.remove(bak)
os.rename(DEBUG_LOG, bak)
except Exception:
pass
with open(DEBUG_LOG, "a", encoding="utf-8") as f:
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
except Exception:
pass
def _char_width(c: str) -> int:
"""Return terminal column width of a single character (1 or 2)."""
o = ord(c)
# Common wide ranges: CJK, emojis, dingbats, symbols, etc.
if (
0x1100 <= o <= 0x115F # Hangul Jamo
or 0x2329 <= o <= 0x232A # angle brackets
or 0x2E80 <= o <= 0x303E # CJK radicals / ideographic
or 0x3040 <= o <= 0x33BF # Hiragana / Katakana / CJK compat
or 0x3400 <= o <= 0x4DBF # CJK Unified Extension A
or 0x4E00 <= o <= 0xA4CF # CJK Unified / Yi
or 0xA960 <= o <= 0xA97C # Hangul Jamo Extended-A
or 0xAC00 <= o <= 0xD7A3 # Hangul Syllables
or 0xF900 <= o <= 0xFAFF # CJK Compatibility Ideographs
or 0xFE10 <= o <= 0xFE6F # CJK compat forms / small forms
or 0xFF01 <= o <= 0xFF60 # Fullwidth forms
or 0xFFE0 <= o <= 0xFFE6 # Fullwidth signs
or 0x1F000 <= o <= 0x1FAFF # Mahjong, Domino, Playing Cards, Emojis, Symbols
or 0x20000 <= o <= 0x2FA1F # CJK Unified Extension B-F
or 0x2600 <= o <= 0x27BF # Misc symbols, Dingbats
or 0x2700 <= o <= 0x27BF # Dingbats
or 0xFE00 <= o <= 0xFE0F # Variation selectors (zero-width but paired with emoji)
or 0x200D == o # ZWJ (zero-width joiner)
or 0x231A <= o <= 0x231B # Watch, Hourglass
or 0x23E9 <= o <= 0x23F3 # Various symbols
or 0x23F8 <= o <= 0x23FA # Various symbols
or 0x25AA <= o <= 0x25AB # Small squares
or 0x25B6 == o or 0x25C0 == o # Play buttons
or 0x25FB <= o <= 0x25FE # Medium squares
or 0x2614 <= o <= 0x2615 # Umbrella, Hot beverage
or 0x2648 <= o <= 0x2653 # Zodiac signs
or 0x267F == o # Wheelchair
or 0x2693 == o # Anchor
or 0x26A1 == o # High voltage (⚡)
or 0x26AA <= o <= 0x26AB # Circles
or 0x26BD <= o <= 0x26BE # Soccer, Baseball
or 0x26C4 <= o <= 0x26C5 # Snowman, Sun behind cloud
or 0x26D4 == o # No entry
or 0x26EA == o # Church
or 0x26F2 <= o <= 0x26F3 # Fountain, Golf
or 0x26F5 == o # Sailboat
or 0x26FA == o # Tent
or 0x26FD == o # Fuel pump
or 0x2702 == o # Scissors
or 0x2705 == o # Check mark
or 0x2708 <= o <= 0x270D # Various
or 0x270F == o # Pencil
or 0x2753 <= o <= 0x2755 # Question marks (❓❔❕)
or 0x2757 == o # Exclamation
or 0x2795 <= o <= 0x2797 # Plus, Minus, Divide
or 0x27B0 == o or 0x27BF == o # Curly loop
):
return 2
# Zero-width characters
if o in (0xFE0F, 0xFE0E, 0x200D, 0x200B, 0x200C, 0x200E, 0x200F):
return 0
return 1
def _vl(s: str) -> int:
"""Visible length of a string, accounting for ANSI codes and wide chars."""
clean = _ansi_re.sub("", s)
return sum(_char_width(c) for c in clean)
def _w(text: str):
sys.stdout.write(text)
def _fl():
sys.stdout.flush()
def enable_ansi():
if sys.platform == "win32":
os.system("")
try:
import ctypes
k = ctypes.windll.kernel32
h = k.GetStdHandle(-11)
m = ctypes.c_ulong()
k.GetConsoleMode(h, ctypes.byref(m))
k.SetConsoleMode(h, m.value | 0x0004)
except Exception:
pass
def term_size() -> Tuple[int, int]:
try:
c, r = os.get_terminal_size()
return max(c, 60), max(r, 20)
except Exception:
return 80, 24
def _read_key_blocking() -> str:
"""Read a single key press (blocking). Returns key name."""
if sys.platform == "win32":
import msvcrt
k = msvcrt.getch()
if k in (b"\x00", b"\xe0"):
k2 = msvcrt.getch()
return {b"H": "up", b"P": "down", b"K": "left", b"M": "right"}.get(k2, "")
if k == b"\r":
return "enter"
if k == b"\x03":
return "ctrl-c"
if k == b"\x1b":
return "esc"
return k.decode("latin-1", errors="replace")
else:
import select as _sel
import termios, tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
ch = sys.stdin.read(1)
if ch == "\x1b":
rdy, _, _ = _sel.select([sys.stdin], [], [], 0.2)
if rdy:
ch2 = sys.stdin.read(1)
if ch2 == "[":
rdy2, _, _ = _sel.select([sys.stdin], [], [], 0.2)
if rdy2:
ch3 = sys.stdin.read(1)
return {"A": "up", "B": "down", "C": "right", "D": "left"}.get(ch3, "esc")
return "esc"
return "esc"
if ch == "\r" or ch == "\n":
return "enter"
if ch == "\x03":
return "ctrl-c"
return ch
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _read_key_nb(timeout: float = 0.05) -> Optional[str]:
"""Non-blocking key read. Returns None if no key."""
if sys.platform == "win32":
import msvcrt
if msvcrt.kbhit():
return _read_key_blocking()
time.sleep(timeout)
return None
else:
import select
import termios, tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
rdy, _, _ = select.select([sys.stdin], [], [], timeout)
if rdy:
ch = sys.stdin.read(1)
if ch == "\x1b":
# Wait for escape sequence bytes (longer timeout for SSH)
rdy2, _, _ = select.select([sys.stdin], [], [], 0.2)
if rdy2:
ch2 = sys.stdin.read(1)
if ch2 == "[":
rdy3, _, _ = select.select([sys.stdin], [], [], 0.2)
if rdy3:
ch3 = sys.stdin.read(1)
return {"A": "up", "B": "down", "C": "right", "D": "left"}.get(ch3, "")
return ""
return "esc" # bare Esc key
if ch in ("\r", "\n"):
return "enter"
if ch == "\x03":
return "ctrl-c"
return ch
return None
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _wait_any_key():
"""Simple blocking wait for any keypress. More robust than _read_key_blocking for popups."""
if sys.platform == "win32":
import msvcrt
msvcrt.getch()
else:
import termios, tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def _prompt_number(prompt: str, max_val: int) -> Optional[int]:
"""Show prompt, read a number from user. Returns None if cancelled."""
_w(A.SHOW)
_w(f"\n {prompt}")
_fl()
buf = ""
if sys.platform == "win32":
import msvcrt
while True:
k = msvcrt.getch()
if k == b"\r":
break
if k == b"\x1b" or k == b"\x03":
_w("\n")
return None
if k == b"\x08" and buf:
buf = buf[:-1]
_w("\b \b")
_fl()
continue
ch = k.decode("latin-1", errors="replace")
if ch.isdigit():
buf += ch
_w(ch)
_fl()
else:
import termios, tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
while True:
ch = sys.stdin.read(1)
if ch in ("\r", "\n"):
break
if ch == "\x1b" or ch == "\x03":
_w("\n")
return None
if ch == "\x7f" and buf: # backspace
buf = buf[:-1]
_w("\b \b")
_fl()
continue
if ch.isdigit():
buf += ch
_w(ch)
_fl()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
_w(A.HIDE)
if buf and buf.isdigit():
n = int(buf)
if 1 <= n <= max_val:
return n
return None
def _fmt_elapsed(secs: float) -> str:
m, s = divmod(int(secs), 60)
if m > 0:
return f"{m}m {s:02d}s"
return f"{s}s"
@dataclass
class ConfigEntry:
address: str
name: str = ""
original_uri: str = ""
ip: str = ""
@dataclass
class RoundCfg:
size: int
keep: int
@property
def label(self) -> str:
if self.size >= 1_000_000:
return f"{self.size // 1_000_000}MB"
return f"{self.size // 1000}KB"
@dataclass
class Result:
ip: str
domains: List[str] = field(default_factory=list)
uris: List[str] = field(default_factory=list)
tcp_ms: float = -1
tls_ms: float = -1
ttfb_ms: float = -1
speeds: List[float] = field(default_factory=list)
best_mbps: float = -1
colo: str = ""
score: float = 0
error: str = ""
alive: bool = False
class State:
def __init__(self):
self.input_file = ""
self.configs: List[ConfigEntry] = []
self.ip_map: Dict[str, List[ConfigEntry]] = defaultdict(list)
self.ips: List[str] = []
self.res: Dict[str, Result] = {}
self.rounds: List[RoundCfg] = []
self.mode = "normal"
self.phase = "init"
self.phase_label = ""
self.cur_round = 0
self.total = 0
self.done_count = 0
self.alive_n = 0
self.dead_n = 0
self.best_speed = 0.0
self.start_time = 0.0
self.notify = "" # notification message shown in footer
self.notify_until = 0.0
self.top = 50 # export top N (0 = all)
self.finished = False
self.interrupted = False
self.saved = False
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.
CF allows ~600 requests per 10-minute window to speed.cloudflare.com.
When 429 is received, retry-after header tells us exactly when the
window resets. We track request count and pause when budget runs out
or when CF explicitly tells us to wait.
"""
BUDGET = 550 # conservative limit (CF allows ~600)
WINDOW = 600 # 10-minute window in seconds
def __init__(self):
self.count = 0
self.window_start = 0.0
self.blocked_until = 0.0
self._lock = asyncio.Lock()
async def _wait_blocked(self, st: Optional["State"]):
"""Wait out a 429 block period (called outside lock)."""
while time.monotonic() < self.blocked_until:
if st and st.interrupted:
return
left = int(self.blocked_until - time.monotonic())
if st:
st.phase_label = f"CF rate limit — resuming in {left}s"
await asyncio.sleep(1)
async def _wait_budget(self, wait_until: float, st: Optional["State"]):
"""Wait for window reset when budget exhausted (called outside lock)."""
while time.monotonic() < wait_until:
if st and st.interrupted:
return
left = int(wait_until - time.monotonic())
if st:
st.phase_label = f"Rate limit ({self.count} reqs) — next window in {left}s"
await asyncio.sleep(1)
async def acquire(self, st: Optional["State"] = None):
"""Wait if we're rate-limited, then count a request."""
# Wait out any 429 block first (outside lock so others can also wait)
if self.blocked_until > 0 and time.monotonic() < self.blocked_until:
_dbg(f"RATE: waiting {self.blocked_until - time.monotonic():.0f}s for CF window reset")
await self._wait_blocked(st)
await self._lock.acquire()
try:
# Re-check after acquiring lock
if self.blocked_until > 0 and time.monotonic() >= self.blocked_until:
self.count = 0
self.window_start = time.monotonic()
self.blocked_until = 0.0
now = time.monotonic()
if self.window_start == 0.0:
self.window_start = now
if now - self.window_start >= self.WINDOW:
self.count = 0
self.window_start = now
if self.count >= self.BUDGET:
remaining = self.WINDOW - (now - self.window_start)
if remaining > 0:
_dbg(f"RATE: budget exhausted ({self.count} reqs), waiting {remaining:.0f}s")
wait_until = self.window_start + self.WINDOW
saved_window = self.window_start
self._lock.release()
try:
await self._wait_budget(wait_until, st)
finally:
await self._lock.acquire()
# Only reset if no other coroutine already did
if self.window_start == saved_window:
self.count = 0
self.window_start = time.monotonic()
else:
self.count = 0
self.window_start = time.monotonic()
self.count += 1
finally:
self._lock.release()
def would_block(self) -> bool:
"""Check if speed.cloudflare.com is currently rate-limited."""
now = time.monotonic()
if self.blocked_until > 0 and now < self.blocked_until:
return True
if self.window_start > 0 and now - self.window_start < self.WINDOW:
if self.count >= self.BUDGET:
return True
return False
def report_429(self, retry_after: int):
"""CF told us to wait. Set blocked_until so all workers pause.
Cap at 600s (10 min) — CF's actual window is 10 min but it sends
punitive retry-after (3600+) after repeated violations."""
capped = min(max(retry_after, 30), 600)
until = time.monotonic() + capped
if until > self.blocked_until:
self.blocked_until = until
_dbg(f"RATE: 429 received (retry-after={retry_after}s, capped={capped}s)")
def build_dynamic_rounds(mode: str, alive_count: int) -> List[RoundCfg]:
"""Build round configs dynamically based on mode and alive IP count."""
preset = PRESETS.get(mode, PRESETS["normal"])
if not preset.get("dynamic"):
return [RoundCfg(1_000_000, alive_count)]
sizes = preset["round_sizes"]
pcts = preset["round_pcts"]
mins = preset["round_min"]
maxs = preset["round_max"]
# Small sets (<50 IPs): test ALL in every round — no funnel needed
small_set = alive_count <= 50
rounds = []
for size, pct, mn, mx in zip(sizes, pcts, mins, maxs):
if small_set:
keep = alive_count
else:
keep = int(alive_count * pct / 100) if pct < 100 else alive_count
if mn > 0:
keep = max(mn, keep)
if mx > 0:
keep = min(mx, keep)
keep = min(keep, alive_count)
if keep > 0:
rounds.append(RoundCfg(size, keep))
return rounds
def parse_vless(uri: str) -> Optional[ConfigEntry]:
uri = uri.strip()
if not uri.startswith("vless://"):
return None
rest = uri[8:]
name = ""
if "#" in rest:
rest, name = rest.rsplit("#", 1)
name = urllib.parse.unquote(name)
if "?" in rest:
rest = rest.split("?", 1)[0]
if "@" not in rest:
return None
_, addr = rest.split("@", 1)
if addr.startswith("["):
if "]" not in addr:
return None
address = addr[1 : addr.index("]")]
else:
address = addr.rsplit(":", 1)[0]
return ConfigEntry(address=address, name=name, original_uri=uri.strip())
def parse_vmess(uri: str) -> Optional[ConfigEntry]:
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 Exception:
raw = base64.urlsafe_b64decode(b64).decode("utf-8", errors="replace")
obj = json.loads(raw)
if not isinstance(obj, dict):
return None
except Exception:
return None
address = str(obj.get("add", ""))
if not address:
return None
name = str(obj.get("ps", ""))
return ConfigEntry(address=address, name=name, original_uri=uri.strip())
def parse_config(uri: str) -> Optional[ConfigEntry]:
"""Try parsing as VLESS or VMess."""
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:
raw = f.read()
except (FileNotFoundError, PermissionError, OSError) as e:
print(f" Error reading {path}: {e}")
return []
try:
data = json.loads(raw)
if isinstance(data, dict) and "data" in data:
data = data["data"]
out: List[ConfigEntry] = []
for i, e in enumerate(data):
d = e.get("domain", "")
if d:
out.append(
ConfigEntry(address=d, name=f"d-{i+1}", ip=e.get("ipv4", ""))
)
if out:
return out
except (json.JSONDecodeError, TypeError, AttributeError):
pass
out = []
for ln in raw.splitlines():
c = parse_config(ln)
if c:
out.append(c)
return out
def fetch_sub(url: str) -> List[ConfigEntry]:
"""Fetch configs from a subscription URL (base64 or plain VLESS URIs)."""
if not url.lower().startswith(("http://", "https://")):
print(f" Error: --sub only accepts http:// or https:// URLs")
return []
_dbg(f"Fetching subscription: {url}")
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8", errors="replace").strip()
except Exception as e:
_dbg(f"Subscription fetch failed: {e}")
print(f" Error fetching subscription: {e}")
return []
try:
decoded = base64.b64decode(raw).decode("utf-8", errors="replace")
if "://" in decoded:
raw = decoded
except Exception:
pass
out = []
for ln in raw.splitlines():
c = parse_config(ln.strip())
if c:
out.append(c)
_dbg(f"Subscription loaded: {len(out)} configs")
return out
def generate_from_template(template: str, addresses: List[str]) -> List[ConfigEntry]:
"""Generate configs by substituting addresses into a VLESS/VMess template."""
out = []
parsed = parse_config(template)
if not parsed:
return out
for i, addr in enumerate(addresses):
addr = addr.strip()
if not addr:
continue
# Handle ip:port format (e.g. from multi-port clean scan)
addr_ip = addr
addr_port = None
if ":" in addr and not addr.startswith("["):
parts = addr.rsplit(":", 1)
if parts[1].isdigit():
addr_ip, addr_port = parts[0], parts[1]
uri = re.sub(
r"(@)(\[[^\]]+\]|[^:]+)(:|$)",
lambda m: m.group(1) + addr_ip + m.group(3),
template,
count=1,
)
if addr_port:
# Replace existing port, or insert port if template had none
if re.search(r"@[^:/?#]+:\d+", uri):
uri = re.sub(r"(@[^:/?#]+:)\d+", lambda m: m.group(1) + addr_port, uri, count=1)
else:
uri = re.sub(r"(@[^/?#]+)([?/#])", lambda m: m.group(1) + ":" + addr_port + m.group(2), uri, count=1)
uri = re.sub(r"#.*$", f"#cfg-{i+1}-{addr_ip[:20]}", uri)
c = parse_config(uri)
if c:
out.append(c)
return out
def load_addresses(path: str) -> List[str]:
"""Load address list from JSON array or plain text (one per line)."""
try:
with open(path, "r", encoding="utf-8") as f:
raw = f.read()
except (FileNotFoundError, PermissionError, OSError) as e:
print(f" Error reading {path}: {e}")
return []
try:
data = json.loads(raw)
if isinstance(data, list):
return [str(d) for d in data if d]
if isinstance(data, dict):
for key in ("addresses", "domains", "ips", "data"):
if key in data and isinstance(data[key], list):
return [str(d) for d in data[key] if d]
except (json.JSONDecodeError, TypeError):
pass
return [ln.strip() for ln in raw.splitlines() if ln.strip()]
def _split_to_24s(subnets: List[str]) -> list:
"""Split CIDR subnets into /24 blocks, deduplicate."""
seen = set()
blocks = []
for sub in subnets:
try:
net = ipaddress.IPv4Network(sub.strip(), strict=False)
if net.prefixlen <= 24:
for block in net.subnets(new_prefix=24):
key = int(block.network_address)
if key not in seen:
seen.add(key)
blocks.append(block)
else:
key = int(net.network_address)
if key not in seen:
seen.add(key)
blocks.append(net)
except (ValueError, TypeError):
continue
return blocks
def generate_cf_ips(subnets: List[str], sample_per_24: int = 0) -> List[str]:
"""Generate IPs from CIDR subnets. sample_per_24=0 means all hosts."""
blocks = _split_to_24s(subnets)
random.shuffle(blocks)
ips = []
for net in blocks:
hosts = [str(ip) for ip in net.hosts()]
if sample_per_24 > 0 and sample_per_24 < len(hosts):
hosts = random.sample(hosts, sample_per_24)
ips.extend(hosts)
return ips
async def _tls_probe(
ip: str, sni: str, timeout: float, validate: bool = True, port: int = 443,
) -> Tuple[float, bool, str]:
"""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
ctx.verify_mode = ssl.CERT_NONE
t0 = time.monotonic()
r, w = await asyncio.wait_for(
asyncio.open_connection(ip, port, ssl=ctx, server_hostname=sni),
timeout=timeout,
)
tls_ms = (time.monotonic() - t0) * 1000
is_cf = True
htxt = ""
if validate:
is_cf = False
try:
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 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 OSError:
pass
w = None
return tls_ms, is_cf, cf_err
except asyncio.TimeoutError:
return -1, False, "timeout"
except OSError as e:
return -1, False, str(e)[:40]
finally:
if w:
try:
w.close()
except OSError:
pass
@dataclass
class CleanScanState:
"""State for clean IP scanning progress."""
total: int = 0
done: int = 0
found: int = 0
interrupted: bool = False
results: List[Tuple[str, float]] = field(default_factory=list) # top 20 for display
all_results: List[Tuple[str, float]] = field(default_factory=list) # full reference
start_time: float = 0.0
async def scan_clean_ips(
ips: List[str],
sni: str = "speed.cloudflare.com",
workers: int = 500,
timeout: float = 3.0,
validate: bool = True,
cs: Optional[CleanScanState] = None,
ports: Optional[List[int]] = None,
) -> List[Tuple[str, float]]:
"""Scan IPs for TLS + optional CF validation. Returns [(addr, latency_ms)] sorted.
addr is 'ip' for port 443, or 'ip:port' for other ports."""
if ports is None:
ports = [443]
sem = asyncio.Semaphore(workers)
results: List[Tuple[str, float]] = []
lock = asyncio.Lock()
total_probes = len(ips) * len(ports)
if cs:
cs.total = total_probes
cs.done = 0
cs.found = 0
cs.start_time = time.monotonic()
async def probe(ip: str, port: int):
if cs and cs.interrupted:
return
async with sem:
if cs and cs.interrupted:
return
lat, is_cf, _err = await _tls_probe(ip, sni, timeout, validate, port)
if lat > 0 and is_cf:
addr = ip if port == 443 else f"{ip}:{port}"
async with lock:
results.append((addr, lat))
if cs:
cs.found += 1
cs.all_results = results # full reference for Ctrl+C recovery
if cs.found % 10 == 0 or cs.found <= 20:
cs.results = sorted(results, key=lambda x: x[1])[:20]
if cs:
cs.done += 1
# Build flat list of (ip, port) pairs
probes = [(ip, p) for ip in ips for p in ports]
random.shuffle(probes) # spread ports across batches for better coverage
BATCH = 50_000
for i in range(0, len(probes), BATCH):
if cs and cs.interrupted:
break
batch = probes[i : i + BATCH]
tasks = [asyncio.ensure_future(probe(ip, port)) for ip, port in batch]
try:
await asyncio.gather(*tasks, return_exceptions=True)
except asyncio.CancelledError:
break
finally:
for t in tasks:
if not t.done():
t.cancel()
results.sort(key=lambda x: x[1])
return results
def load_configs_from_args(args) -> Tuple[List[ConfigEntry], str]:
"""Load configs based on CLI args. Returns (configs, source_label)."""
if getattr(args, "sub", None):
configs = fetch_sub(args.sub)
return configs, args.sub
if getattr(args, "template", None):
if not getattr(args, "input", None):
return [], "ERROR: --template requires -i (address list file)"
addrs = load_addresses(args.input)
configs = generate_from_template(args.template, addrs)
return configs, f"{args.input} ({len(addrs)} addresses)"
if getattr(args, "input", None):
configs = load_input(args.input)
return configs, args.input
return [], ""
def parse_size(s: str) -> int:
s = s.strip().upper()
m = re.match(r"^(\d+(?:\.\d+)?)\s*(MB|KB|GB|B)?$", s)
if not m:
try:
return max(1, int(s))
except ValueError:
return 1_000_000 # default 1MB
n = float(m.group(1))
u = m.group(2) or "B"
mul = {"B": 1, "KB": 1_000, "MB": 1_000_000, "GB": 1_000_000_000}
return max(1, int(n * mul.get(u, 1)))
def parse_rounds_str(s: str) -> List[RoundCfg]:
out = []
for p in s.split(","):
p = p.strip()
if ":" in p:
sz, top = p.split(":", 1)
try:
out.append(RoundCfg(parse_size(sz), int(top)))
except ValueError:
pass # skip malformed round
return out
def find_config_files() -> List[Tuple[str, str, int]]:
"""Find config files in cwd. Returns [(path, type, count)]."""
results = []
for pat in ("*.txt", "*.json", "*.conf", "*.lst"):
for p in globmod.glob(pat):
try:
with open(p, "r", encoding="utf-8", errors="replace") as f:
head = f.read(2048)
count = 0
json_ok = False
if head.strip().startswith("{") or head.strip().startswith("["):
try:
with open(p, encoding="utf-8") as jf:
d = json.loads(jf.read())
if isinstance(d, dict) and "data" in d:
d = d["data"]
if isinstance(d, list):
count = len(d)
results.append((p, "json", count))
json_ok = True
except Exception:
pass
if not json_ok and ("vless://" in head or "vmess://" in head):
with open(p, encoding="utf-8") as f:
count = sum(1 for ln in f if ln.strip().startswith(("vless://", "vmess://")))
results.append((p, "configs", count))
except Exception:
pass
results.sort(key=lambda x: x[2], reverse=True)
return results
async def _resolve(e: ConfigEntry, sem: asyncio.Semaphore, counter: List[int]) -> ConfigEntry:
if e.ip:
counter[0] += 1
return e
async with sem:
try:
loop = asyncio.get_running_loop()
info = await loop.getaddrinfo(e.address, 443, family=socket.AF_INET)
if info:
e.ip = info[0][4][0]
except Exception:
e.ip = ""
counter[0] += 1
return e
async def resolve_all(st: State, workers: int = 100):
sem = asyncio.Semaphore(workers)
counter = [0] # mutable for closure
total = len(st.configs)
async def _progress():
spin = "|/-\\"
i = 0
while counter[0] < total:
s = spin[i % len(spin)]
pct = counter[0] * 100 // max(1, total)
_w(f"\r {A.CYN}{s}{A.RST} Resolving DNS... {counter[0]}/{total} ({pct}%) ")
_fl()
i += 1
await asyncio.sleep(0.15)
_w(f"\r {A.GRN}OK{A.RST} Resolved {total} domains -> {len(set(c.ip for c in st.configs if c.ip))} unique IPs\n")
_fl()
prog_task = asyncio.create_task(_progress())
try:
st.configs = list(await asyncio.gather(*[_resolve(c, sem, counter) for c in st.configs]))
finally:
prog_task.cancel()
try:
await prog_task
except asyncio.CancelledError:
pass
for c in st.configs:
if c.ip:
st.ip_map[c.ip].append(c)
st.ips = list(st.ip_map.keys())
for ip in st.ips:
cs = st.ip_map[ip]
st.res[ip] = Result(
ip=ip,
domains=[c.address for c in cs],
uris=[c.original_uri for c in cs if c.original_uri],
)
async def _lat_one(ip: str, sni: str, timeout: float) -> Tuple[float, float, str]:
"""Measure TCP RTT and full TLS connection time (TCP+TLS handshake)."""
try:
t0 = time.monotonic()
r, w = await asyncio.wait_for(
asyncio.open_connection(ip, 443), timeout=timeout
)
tcp = (time.monotonic() - t0) * 1000
w.close()
try:
await w.wait_closed()
except Exception:
pass
except asyncio.TimeoutError:
return -1, -1, "tcp-timeout"
except Exception as e:
return -1, -1, f"tcp:{str(e)[:50]}"
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
t0 = time.monotonic()
r, w = await asyncio.wait_for(
asyncio.open_connection(ip, 443, ssl=ctx, server_hostname=sni),
timeout=timeout,
)
tls_full = (time.monotonic() - t0) * 1000 # full TCP+TLS time
w.close()
try:
await w.wait_closed()
except Exception:
pass
return tcp, tls_full, ""
except asyncio.TimeoutError:
return tcp, -1, "tls-timeout"
except Exception as e:
return tcp, -1, f"tls:{str(e)[:50]}"
async def phase1(st: State, workers: int, timeout: float):
st.phase = "latency"
st.phase_label = "Testing latency"
st.total = len(st.ips)
st.done_count = 0
sem = asyncio.Semaphore(workers)
async def go(ip: str):
async with sem:
if st.interrupted:
return
res = st.res[ip]
# Use speed.cloudflare.com as SNI — filters out non-CF IPs early
# (non-CF IPs will fail TLS since they don't serve this cert)
tcp, tls, err = await _lat_one(ip, SPEED_HOST, timeout)
res.tcp_ms = tcp
res.tls_ms = tls
res.error = err
res.alive = tls > 0
st.done_count += 1
if res.alive:
st.alive_n += 1
else:
st.dead_n += 1
tasks = [asyncio.ensure_future(go(ip)) for ip in st.ips]
try:
await asyncio.gather(*tasks, return_exceptions=True)
except asyncio.CancelledError:
pass
finally:
for t in tasks:
if not t.done():
t.cancel()
async def _dl_one(
ip: str, size: int, timeout: float,
host: str = "", path: str = "",
) -> Tuple[float, float, int, str, str]:
"""Download test. Returns (ttfb_ms, mbps, bytes, colo, error).
Error "429" means rate-limited — caller should back off."""
if not host:
host = SPEED_HOST
if not path:
path = f"{SPEED_PATH}?bytes={size}"
dl_timeout = max(timeout, 30 + (size / 1_000_000) * 2)
conn_timeout = min(timeout, 15)
w = None
total = 0
dl_start = 0.0
ttfb = 0.0
colo = ""
def _cleanup():
nonlocal w
if w is not None:
try:
w.close()
except Exception:
pass
w = None
try:
ctx = ssl.create_default_context()
t_start = time.monotonic()
try:
t0 = t_start
r, w = await asyncio.wait_for(
asyncio.open_connection(ip, 443, ssl=ctx, server_hostname=host),
timeout=conn_timeout,
)
except ssl.SSLCertVerificationError:
_cleanup()
ctx2 = ssl.create_default_context()
ctx2.check_hostname = False
ctx2.verify_mode = ssl.CERT_NONE
t0 = time.monotonic()
r, w = await asyncio.wait_for(
asyncio.open_connection(
ip, 443, ssl=ctx2, server_hostname=host
),
timeout=conn_timeout,
)
conn_ms = (time.monotonic() - t0) * 1000
range_hdr = ""
if "bytes=" not in path:
range_hdr = f"Range: bytes=0-{size - 1}\r\n"
req = (
f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"User-Agent: Mozilla/5.0 (X11; Linux x86_64) Chrome/120\r\n"
f"Accept: */*\r\n"
f"{range_hdr}"
f"Connection: close\r\n\r\n"
)
w.write(req.encode())
await w.drain()
hbuf = b""
while b"\r\n\r\n" not in hbuf:
ch = await asyncio.wait_for(r.read(4096), timeout=min(conn_timeout, 10))
if not ch:
_dbg(f"DL {ip} {size}: empty response (no headers)")
return -1, 0, 0, "", "empty"
hbuf += ch
if len(hbuf) > 65536:
_dbg(f"DL {ip} {size}: header too big")
return -1, 0, 0, "", "hdr-too-big"
sep = hbuf.index(b"\r\n\r\n") + 4
htxt = hbuf[:sep].decode("latin-1", errors="replace")
body0 = hbuf[sep:]
status_line = htxt.split("\r\n")[0]
status_parts = status_line.split(None, 2)
status_code = status_parts[1] if len(status_parts) >= 2 else ""
if status_code == "429":
ra = ""
for line in htxt.split("\r\n"):
if line.lower().startswith("retry-after:"):
ra = line.split(":", 1)[1].strip()
break
_dbg(f"DL {ip} {size}: 429 rate-limited (retry-after={ra})")
return -1, 0, 0, "", f"429:{ra}"
if status_code not in ("200", "206"):
_dbg(f"DL {ip} {size}: HTTP error: {status_line[:80]}")
return -1, 0, 0, "", f"http:{status_line[:40]}"
for line in htxt.split("\r\n"):
if line.lower().startswith("cf-ray:"):
ray = line.split(":", 1)[1].strip()
if "-" in ray:
colo = ray.rsplit("-", 1)[-1]
break
ttfb = (time.monotonic() - t0) * 1000 - conn_ms
dl_start = time.monotonic()
total = len(body0)
sample_interval = 1_000_000 if size >= 5_000_000 else size + 1
next_sample = sample_interval
samples: List[Tuple[int, float]] = []
min_for_stable = min(size // 2, 20_000_000) if size >= 5_000_000 else size
min_samples = 5 if size >= 10_000_000 else 3
while True:
try:
elapsed_total = time.monotonic() - t_start
left = max(1.0, dl_timeout - elapsed_total)
ch = await asyncio.wait_for(r.read(65536), timeout=min(left, 10))
if not ch:
break
total += len(ch)
if total >= next_sample:
elapsed = time.monotonic() - dl_start
samples.append((total, elapsed))
next_sample += sample_interval
# only check stability after enough data downloaded
if len(samples) >= min_samples and total >= min_for_stable:
recent = samples[-4:]
sp = []
for j in range(1, len(recent)):
db = recent[j][0] - recent[j - 1][0]
dt = recent[j][1] - recent[j - 1][1]
if dt > 0:
sp.append(db / dt)
if len(sp) >= 2:
mn = statistics.mean(sp)
if mn > 0:
try:
sd = statistics.stdev(sp)
if sd / mn < 0.10:
break
except statistics.StatisticsError:
pass
except asyncio.TimeoutError:
break
except Exception:
break
dl_t = time.monotonic() - dl_start
mbps = (total / 1_000_000) / dl_t if dl_t > 0 else 0
_dbg(f"DL {ip} {size}: OK {mbps:.2f}MB/s total={total} dt={dl_t:.1f}s host={host}")
return ttfb, mbps, total, colo, ""
except asyncio.TimeoutError:
if total > 0 and dl_start > 0:
dl_t = time.monotonic() - dl_start
mbps = (total / 1_000_000) / dl_t if dl_t > 0 else 0
_dbg(f"DL {ip} {size}: TIMEOUT partial={total}B mbps={mbps:.2f} dt={dl_t:.1f}s")
if mbps > 0:
return ttfb, mbps, total, colo, ""
_dbg(f"DL {ip} {size}: TIMEOUT no data total={total}")
return -1, 0, 0, "", "timeout"
except Exception as e:
if total > 0 and dl_start > 0:
dl_t = time.monotonic() - dl_start
mbps = (total / 1_000_000) / dl_t if dl_t > 0 else 0
_dbg(f"DL {ip} {size}: ERR partial={total}B mbps={mbps:.2f} err={e}")
if mbps > 0:
return ttfb, mbps, total, colo, ""
_dbg(f"DL {ip} {size}: ERR no data err={e}")
return -1, 0, 0, "", str(e)[:60]
finally:
_cleanup()
async def phase2_round(
st: State,
rcfg: RoundCfg,
candidates: List[str],
workers: int,
timeout: float,
rlim: Optional[CFRateLimiter] = None,
cdn_host: str = "",
cdn_path: str = "",
):
st.total = len(candidates)
st.done_count = 0
if rcfg.size >= 50_000_000:
workers = min(workers, 6)
elif rcfg.size >= 10_000_000:
workers = min(workers, 8)
sem = asyncio.Semaphore(workers)
max_retries = 2
async def go(ip: str):
best_mbps_this = 0.0
best_ttfb = -1.0
best_colo = ""
last_err = ""
force_cdn = False # set True when CF rejects (403/429)
for attempt in range(max_retries):
if st.interrupted:
break
# Pick endpoint: speed.cloudflare.com if budget available, else fallback CDN
use_host = cdn_host
use_path = cdn_path
if force_cdn and CDN_FALLBACK:
use_host, use_path = CDN_FALLBACK
_dbg(f"DL {ip}: forced fallback CDN {use_host}")
elif rlim and rlim.would_block() and CDN_FALLBACK:
use_host, use_path = CDN_FALLBACK
_dbg(f"DL {ip}: using fallback CDN {use_host}")
elif rlim:
await rlim.acquire(st)
# acquire sem for the actual download
await sem.acquire()
try:
if st.interrupted:
break
ttfb, mbps, _total, colo, err = await _dl_one(
ip, rcfg.size, timeout, host=use_host, path=use_path,
)
finally:
sem.release() # free slot immediately after download
if mbps > 0:
best_mbps_this = mbps
best_ttfb = ttfb
best_colo = colo
break
# 429 from speed.cloudflare.com: report + force CDN on retry
if err.startswith("429") and use_host == SPEED_HOST:
ra_str = err.split(":", 1)[1] if ":" in err else ""
try:
ra = int(ra_str)
except (ValueError, TypeError):
ra = 60
if rlim:
rlim.report_429(ra)
_dbg(f"DL {ip}: 429 reported to limiter (retry-after={ra})")
force_cdn = True
# 403 from speed.cloudflare.com: CF rejected size, force CDN
elif err.startswith("http:") and use_host == SPEED_HOST:
_dbg(f"DL {ip}: {err} from CF, switching to CDN fallback")
force_cdn = True
# error from fallback CDN
elif err.startswith("429") or err.startswith("http:"):
_dbg(f"DL {ip}: {err} from {use_host}, will retry")
last_err = err
res = st.res[ip]
res.speeds.append(best_mbps_this)
if best_mbps_this > 0:
if best_mbps_this > res.best_mbps:
res.best_mbps = best_mbps_this
if best_ttfb > 0 and (res.ttfb_ms < 0 or best_ttfb < res.ttfb_ms):
res.ttfb_ms = best_ttfb
if best_colo and not res.colo:
res.colo = best_colo
if best_mbps_this > st.best_speed:
st.best_speed = best_mbps_this
elif last_err:
res.error = last_err
st.done_count += 1
tasks = [asyncio.ensure_future(go(ip)) for ip in candidates]
try:
await asyncio.gather(*tasks, return_exceptions=True)
except asyncio.CancelledError:
pass
finally:
for t in tasks:
if not t.done():
t.cancel()
def calc_scores(st: State):
has_speed = any(r.best_mbps > 0 for r in st.res.values())
for r in st.res.values():
if not r.alive:
r.score = 0
continue
lat = max(0, 100 - r.tls_ms / 10) if r.tls_ms > 0 else 0
spd = min(100, r.best_mbps * 20) if r.best_mbps > 0 else 0
ttfb = max(0, 100 - r.ttfb_ms / 5) if r.ttfb_ms > 0 else 0
if r.best_mbps > 0:
r.score = round(lat * 0.35 + spd * 0.50 + ttfb * 0.15, 1)
elif has_speed:
# Speed rounds ran but this IP wasn't tested - rank below tested ones
r.score = round(lat * 0.35, 1)
else:
# No speed rounds at all (latency-only mode)
r.score = round(lat, 1)
def sorted_alive(st: State, key: str = "score") -> List[Result]:
alive = [r for r in st.res.values() if r.alive]
if key == "score":
alive.sort(key=lambda r: r.score, reverse=True)
elif key == "latency":
alive.sort(key=lambda r: r.tls_ms)
elif key == "speed":
alive.sort(key=lambda r: r.best_mbps, reverse=True)
return alive
def sorted_all(st: State, key: str = "score") -> List[Result]:
"""Return all results: alive sorted by key, then dead at the bottom."""
alive = sorted_alive(st, key)
dead = [r for r in st.res.values() if not r.alive]
dead.sort(key=lambda r: r.ip)
return alive + dead
def draw_menu_header(cols: int) -> List[str]:
W = cols - 2
lines = []
lines.append(f"{A.CYN}{'' * W}{A.RST}")
t = f" {A.BOLD}{A.WHT}CF Config Scanner{A.RST} {A.DIM}v{VERSION}{A.RST}"
lines.append(f"{A.CYN}{A.RST}" + t + " " * (W - _vl(t)) + f"{A.CYN}{A.RST}")
lines.append(f"{A.CYN}{'' * W}{A.RST}")
return lines
def draw_box_line(content: str, cols: int) -> str:
W = cols - 2
vl = _vl(content)
pad = " " * max(0, W - vl)
return f"{A.CYN}{A.RST}{content}{pad}{A.CYN}{A.RST}"
def draw_box_sep(cols: int) -> str:
return f"{A.CYN}{'' * (cols - 2)}{A.RST}"
def draw_box_bottom(cols: int) -> str:
return f"{A.CYN}{'' * (cols - 2)}{A.RST}"
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)
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}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}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.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}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}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}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",
"",
]
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.",
"",
]
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]:
"""Pick scan scope for clean IP finder. Returns mode or None/'__back__'."""
while True:
_w(A.CLR + A.HOME + A.HIDE)
cols, _ = term_size()
lines = draw_menu_header(cols)
lines.append(draw_box_line(f" {A.BOLD}Find Clean Cloudflare IPs{A.RST}", cols))
lines.append(draw_box_line(f" {A.DIM}Scans Cloudflare IP ranges to find reachable edge IPs{A.RST}", cols))
lines.append(draw_box_line("", cols))
lines.append(draw_box_sep(cols))
lines.append(draw_box_line(f" {A.BOLD}Select scan scope:{A.RST}", cols))
lines.append(draw_box_line("", cols))
for name, key in [("quick", "1"), ("normal", "2"), ("full", "3"), ("mega", "4")]:
cfg = CLEAN_MODES[name]
num = f"{A.CYN}{A.BOLD}{key}{A.RST}"
lbl = f"{A.BOLD}{cfg['label']}{A.RST}"
if name == "normal":
lbl += f" {A.GRN}(recommended){A.RST}"
lines.append(draw_box_line(f" {num} {lbl}", cols))
desc = cfg["desc"]
if len(cfg.get("ports", [])) > 1:
desc += f" (ports: {', '.join(str(p) for p in cfg['ports'])})"
lines.append(draw_box_line(f" {A.DIM}{desc}{A.RST}", cols))
lines.append(draw_box_line("", cols))
lines.append(draw_box_sep(cols))
lines.append(draw_box_line(f" {A.DIM}[1-4] Select [B] Back [Q] Quit{A.RST}", cols))
lines.append(draw_box_bottom(cols))
_w("\n".join(lines) + "\n")
_fl()
key = _read_key_blocking()
if key in ("q", "ctrl-c"):
return None
if key in ("b", "esc"):
return "__back__"
if key == "1":
return "quick"
if key == "2" or key == "enter":
return "normal"
if key == "3":
return "full"
if key == "4":
return "mega"
def _draw_clean_progress(cs: CleanScanState):
"""Draw live progress screen for clean IP scan."""
cols, rows = term_size()
W = cols - 2
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}")
out.append(f"{A.CYN}{'' * W}{A.RST}")
elapsed = _fmt_elapsed(time.monotonic() - cs.start_time) if cs.start_time else "0s"
title = f" {A.BOLD}{A.WHT}Finding Clean Cloudflare IPs{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}")
pct = cs.done * 100 // max(1, cs.total)
bw = max(1, min(30, W - 40))
filled = int(bw * pct / 100)
bar = f"{A.GRN}{'' * filled}{A.DIM}{'' * (bw - filled)}{A.RST}"
bx(f" Probing [{bar}] {cs.done:,}/{cs.total:,} {pct}%")
found_line = f" {A.GRN}Found: {cs.found:,} clean IPs{A.RST}"
if cs.results:
best_lat = cs.results[0][1]
found_line += f" {A.DIM}Best: {best_lat:.0f}ms{A.RST}"
bx(found_line)
out.append(f"{A.CYN}{'' * W}{A.RST}")
bx(f" {A.BOLD}Top IPs found (by latency):{A.RST}")
vis = min(15, rows - 12)
if cs.results:
for i, (ip, lat) in enumerate(cs.results[:vis]):
bx(f" {A.CYN}{i+1:>3}.{A.RST} {ip:<22} {A.GRN}{lat:>6.0f}ms{A.RST}")
else:
bx(f" {A.DIM}Scanning...{A.RST}")
# Fill remaining space
used = len(cs.results[:vis]) if cs.results else 1
for _ in range(vis - used):
bx("")
out.append(f"{A.CYN}{'' * W}{A.RST}")
bx(f" {A.DIM}Press Ctrl+C to stop early and show results{A.RST}")
out.append(f"{A.CYN}{'' * W}{A.RST}")
_w(A.HOME)
_w("\n".join(out) + "\n")
_fl()
def _clean_show_results(results: List[Tuple[str, float]], elapsed: str) -> Optional[str]:
"""Show clean IP results with j/k scrolling. Returns action string or None."""
MAX_SHOW = 300
display = results[:MAX_SHOW]
offset = 0
while True:
_w(A.CLR + A.HOME + A.HIDE)
cols, rows = term_size()
lines = draw_menu_header(cols)
if results:
lines.append(draw_box_line(
f" {A.BOLD}{A.GRN}Scan Complete!{A.RST} "
f"Found {A.BOLD}{len(results):,}{A.RST} clean IPs in {elapsed}", cols))
else:
lines.append(draw_box_line(f" {A.YEL}Scan Complete — no clean IPs found.{A.RST}", cols))
lines.append(draw_box_sep(cols))
if display:
# header + separator = 2 rows, footer area = 5 rows, menu header = 3 rows
vis = max(5, rows - 13)
end = min(len(display), offset + vis)
hdr = f" {A.BOLD}{'#':>4} {'Address':<22} {'Latency':>8}{A.RST}"
if len(display) > vis:
pos = f"{A.DIM}[{offset+1}-{end} of {len(display)}"
if len(results) > MAX_SHOW:
pos += f", {len(results):,} total"
pos += f"]{A.RST}"
hdr += " " * max(1, cols - 2 - _vl(hdr) - _vl(pos) - 1) + pos
lines.append(draw_box_line(hdr, cols))
lines.append(draw_box_line(
f" {A.DIM}{''*4} {''*22} {''*8}{A.RST}", cols))
for i in range(offset, end):
ip, lat = display[i]
lines.append(draw_box_line(
f" {i+1:>4} {ip:<22} {A.GRN}{lat:>6.0f}ms{A.RST}", cols))
lines.append(draw_box_line("", cols))
lines.append(draw_box_sep(cols))
ft = ""
if results:
ft += f" {A.CYN}[S]{A.RST} Save all {A.CYN}[T]{A.RST} Template+SpeedTest "
ft += f" {A.CYN}[B]{A.RST} Back"
lines.append(draw_box_line(ft, cols))
if display and len(display) > vis:
lines.append(draw_box_line(
f" {A.DIM}j/↓ down k/↑ up n/p page down/up{A.RST}", cols))
lines.append(draw_box_bottom(cols))
_w("\n".join(lines) + "\n")
_fl()
key = _read_key_blocking()
if key in ("b", "esc", "q", "ctrl-c"):
return "back"
if key in ("j", "down"):
vis = max(5, rows - 13)
offset = min(offset + 1, max(0, len(display) - vis))
continue
if key in ("k", "up"):
offset = max(0, offset - 1)
continue
if key == "n":
vis = max(5, rows - 13)
offset = min(offset + vis, max(0, len(display) - vis))
continue
if key == "p":
vis = max(5, rows - 13)
offset = max(0, offset - vis)
continue
if key == "s" and results:
return "save"
if key == "t" and results:
_w(A.SHOW)
_w(f"\n {A.BOLD}{A.CYN}Speed Test with Clean IPs{A.RST}\n")
_w(f" {A.DIM}Paste a VLESS/VMess config URI. The address in it will be{A.RST}\n")
_w(f" {A.DIM}replaced with each clean IP, then all configs get speed-tested.{A.RST}\n\n")
_w(f" {A.CYN}Template:{A.RST} ")
_fl()
try:
tpl = input().strip()
except (EOFError, KeyboardInterrupt):
continue
if not tpl or not parse_config(tpl):
_w(f" {A.RED}Invalid VLESS/VMess URI.{A.RST}\n")
_fl()
time.sleep(1.5)
continue
return f"template:{tpl}"
async def tui_run_clean_finder() -> Optional[Tuple[str, str]]:
"""Run the clean IP finder flow. Returns (input_method, input_value) or None."""
mode = _clean_pick_mode()
if mode is None:
return None
if mode == "__back__":
return ("__back__", "")
scan_cfg = CLEAN_MODES[mode]
# Generate IPs
_w(A.CLR + A.HOME)
cols, _ = term_size()
lines = draw_menu_header(cols)
lines.append(draw_box_line(
f" {A.BOLD}Generating IPs from {len(CF_SUBNETS)} Cloudflare ranges...{A.RST}", cols))
lines.append(draw_box_bottom(cols))
_w("\n".join(lines) + "\n")
_fl()
ips = generate_cf_ips(CF_SUBNETS, scan_cfg["sample"])
ports = scan_cfg.get("ports", [443])
_dbg(f"CLEAN: Generated {len(ips):,} IPs × {len(ports)} port(s), sample={scan_cfg['sample']}")
# Run scan with live progress
cs = CleanScanState()
scan_task = asyncio.ensure_future(
scan_clean_ips(
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
_loop.call_soon_threadsafe(scan_task.cancel)
signal.signal(signal.SIGINT, _sig)
_w(A.CLR + A.HIDE)
try:
while not scan_task.done():
_draw_clean_progress(cs)
await asyncio.sleep(0.3)
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:
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) if cs.start_time > 0 else "0s"
_dbg(f"CLEAN: Done in {elapsed}. Found {len(results):,} / {len(ips):,}")
# Show results and get user action
action = _clean_show_results(results, elapsed)
if action is None or action == "back":
return ("__back__", "")
if action == "save":
try:
os.makedirs(RESULTS_DIR, exist_ok=True)
path = os.path.abspath(_results_path("clean_ips.txt"))
with open(path, "w", encoding="utf-8") as f:
for ip, lat in results:
f.write(f"{ip}\n")
_w(f"\n {A.GRN}Saved {len(results):,} IPs to {path}{A.RST}\n")
except Exception as e:
_w(f"\n {A.RED}Save error: {e}{A.RST}\n")
_w(f" {A.DIM}Press any key...{A.RST}\n")
_fl()
_wait_any_key()
return ("__back__", "")
if action.startswith("template:"):
template_uri = action[9:]
try:
os.makedirs(RESULTS_DIR, exist_ok=True)
path = os.path.abspath(_results_path("clean_ips.txt"))
with open(path, "w", encoding="utf-8") as f:
for ip, lat in results:
f.write(f"{ip}\n")
except Exception as e:
_w(f"\n {A.RED}Save error: {e}{A.RST}\n")
_fl()
time.sleep(2)
return ("__back__", "")
return ("template", f"{template_uri}|||{path}")
return None
def _tui_prompt_text(label: str) -> Optional[str]:
"""Show cursor, prompt for text input, return stripped text or None."""
_w(A.SHOW)
_w(f"\n {A.CYN}{label}{A.RST} ")
_fl()
try:
val = input().strip()
except (EOFError, KeyboardInterrupt):
return None
return val if val else None
def tui_pick_file() -> Optional[Tuple[str, str]]:
"""Interactive file/input picker. Returns (method, value) or None.
method is one of: 'file', 'sub', 'template'.
For 'file': value is the file path.
For 'sub': value is the subscription URL.
For 'template': value is 'template_uri|||address_file_path'.
"""
enable_ansi()
files = find_config_files()
while True:
_w(A.CLR + A.HOME + A.HIDE)
cols, rows = term_size()
W = cols - 2
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}")
# Single clean box — no internal double-line separators
out.append(f"{A.CYN}{'' * W}{A.RST}")
title = f"{A.BOLD}{A.WHT}cfray{A.RST} {A.DIM}v{VERSION}{A.RST}"
subtitle = f"{A.DIM}Cloudflare Config Scanner{A.RST}"
bx(title + " " + subtitle)
bx("")
# Section: Local Files
bx(f" {A.DIM}── {A.BOLD}{A.WHT}📁 LOCAL FILES{A.RST} {A.DIM}{'' * max(1, W - 19)}{A.RST}")
if files:
for i, (path, ftype, count) in enumerate(files[:9]):
num = f" {A.CYN}{A.BOLD}{i + 1}{A.RST}."
name = os.path.basename(path)
desc = f"{A.DIM}{ftype}, {count} entries{A.RST}"
bx(f" {num} 📄 {name:<28} {desc}")
else:
bx(f" {A.DIM}No config files found in current directory{A.RST}")
bx(f" {A.DIM}Drop .txt or .json files here, or use options below{A.RST}")
bx("")
# Section: Remote Sources
bx(f" {A.DIM}── {A.BOLD}{A.WHT}🌐 REMOTE SOURCES{A.RST} {A.DIM}{'' * max(1, W - 22)}{A.RST}")
bx(f" {A.CYN}{A.BOLD}s{A.RST}. 🔗 {A.WHT}Subscription URL{A.RST} {A.DIM}Fetch configs from remote URL{A.RST}")
bx(f" {A.CYN}{A.BOLD}p{A.RST}. 📂 {A.WHT}Enter File Path{A.RST} {A.DIM}Load from custom file path{A.RST}")
bx("")
# Section: Tools
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}")
out.append(f"{A.CYN}{'' * W}{A.RST}")
_w("\n".join(out) + "\n")
_fl()
key = _read_key_blocking()
if key in ("q", "ctrl-c", "esc"):
_w(A.SHOW)
_fl()
return None
if key == "h":
tui_show_guide()
files = find_config_files()
continue
if key == "p":
path = _tui_prompt_text("Enter file path:")
if path is None:
continue
if os.path.isfile(path):
return ("file", path)
_w(f" {A.RED}File not found.{A.RST}\n")
_fl()
time.sleep(1)
continue
if key == "s":
_w(A.SHOW)
_w(f"\n {A.BOLD}{A.CYN}Subscription URL{A.RST}\n")
_w(f" {A.DIM}Paste a URL that contains VLESS/VMess configs (plain text or base64).{A.RST}\n")
_w(f" {A.DIM}Example: https://example.com/sub.txt{A.RST}\n\n")
_fl()
url = _tui_prompt_text("URL:")
if url is None:
continue
if not url.lower().startswith(("http://", "https://")):
_w(f" {A.RED}URL must start with http:// or https://{A.RST}\n")
_fl()
time.sleep(1.5)
continue
return ("sub", url)
if key == "t":
_w(A.SHOW)
_w(f"\n {A.BOLD}{A.CYN}Template + Address List{A.RST}\n")
_w(f" {A.DIM}This mode takes ONE working config and a list of Cloudflare IPs/domains.{A.RST}\n")
_w(f" {A.DIM}It replaces the address in your config with each IP from the list,{A.RST}\n")
_w(f" {A.DIM}then tests all of them to find the fastest.{A.RST}\n\n")
_w(f" {A.BOLD}Step 1:{A.RST} {A.CYN}Paste your VLESS/VMess config URI:{A.RST}\n")
_w(f" {A.DIM}(a full vless://... or vmess://... URI){A.RST}\n ")
_fl()
try:
tpl = input().strip()
except (EOFError, KeyboardInterrupt):
continue
if not tpl or not parse_config(tpl):
_w(f" {A.RED}Invalid VLESS/VMess URI.{A.RST}\n")
_fl()
time.sleep(1.5)
continue
_w(f"\n {A.BOLD}Step 2:{A.RST} {A.CYN}Enter path to address list file:{A.RST}\n")
_w(f" {A.DIM}(a .txt file with one IP or domain per line){A.RST}\n")
_fl()
addr_path = _tui_prompt_text("Path:")
if addr_path is None:
continue
if not os.path.isfile(addr_path):
_w(f" {A.RED}File not found.{A.RST}\n")
_fl()
time.sleep(1)
continue
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])
def tui_pick_mode() -> Optional[str]:
"""Interactive mode picker. Returns mode name or None."""
while True:
_w(A.CLR + A.HOME + A.HIDE)
cols, _ = term_size()
lines = draw_menu_header(cols)
lines.append(draw_box_line(f" {A.BOLD}Select scan mode:{A.RST}", cols))
lines.append(draw_box_line("", cols))
modes = [("quick", "1"), ("normal", "2"), ("thorough", "3")]
for name, key in modes:
p = PRESETS[name]
num = f"{A.CYN}{A.BOLD}{key}{A.RST}"
lbl = f"{A.BOLD}{p['label']}{A.RST}"
if name == "normal":
lbl += f" {A.GRN}(recommended){A.RST}"
lines.append(draw_box_line(f" {num} {lbl}", cols))
lines.append(
draw_box_line(f" {A.DIM}{p['desc']}{A.RST}", cols)
)
lines.append(
draw_box_line(
f" {A.DIM}Data: {p['data']} | Est. time: {p['time']}{A.RST}",
cols,
)
)
lines.append(draw_box_line("", cols))
lines.append(draw_box_sep(cols))
lines.append(
draw_box_line(
f" {A.DIM}[1-3] Select [B] Back [Q] Quit{A.RST}", cols
)
)
lines.append(draw_box_bottom(cols))
_w("\n".join(lines) + "\n")
_fl()
key = _read_key_blocking()
if key in ("q", "ctrl-c"):
_w(A.SHOW)
_fl()
return None
if key == "b":
return "__back__"
if key == "1":
return "quick"
if key == "2" or key == "enter":
return "normal"
if key == "3":
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_<ts>.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 "<server-ip>"
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 "<server-ip>"
_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 "<server-ip>"
_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
self.sort = "score"
self.offset = 0
self.show_domains = False
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 _cscore(self, v: float) -> str:
if v >= 70:
return f"{A.GRN}{v:5.1f}{A.RST}"
if v >= 40:
return f"{A.YEL}{v:5.1f}{A.RST}"
if v > 0:
return f"{A.RED}{v:5.1f}{A.RST}"
return f"{A.DIM} -{A.RST}"
def _speed_str(self, v: float) -> str:
if v <= 0:
return f"{A.DIM} -{A.RST}"
if v >= 1:
return f"{A.GRN}{v:5.1f}{A.RST}"
return f"{A.YEL}{v * 1000:4.0f}K{A.RST}"
def draw(self):
cols, rows = term_size()
W = cols - 2
s = self.st
vis = max(3, rows - 18 - len(s.rounds))
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}")
out.append(f"{A.CYN}{'' * W}{A.RST}")
elapsed = _fmt_elapsed(time.monotonic() - s.start_time) if s.start_time else "0s"
title = f" {A.BOLD}{A.WHT}CF Config Scanner{A.RST}"
right = f"{A.DIM}{elapsed} | {s.mode} | ^C stop{A.RST}"
bx(title + " " * max(1, W - _vl(title) - _vl(right)) + right)
out.append(f"{A.CYN}{'' * W}{A.RST}")
fname = os.path.basename(s.input_file)
info = f" {A.DIM}File:{A.RST} {fname} {A.DIM}Configs:{A.RST} {len(s.configs)} {A.DIM}Unique IPs:{A.RST} {len(s.ips)}"
if s.latency_cut_n > 0:
info += f" {A.DIM}Cut:{A.RST} {s.latency_cut_n}"
bx(info)
out.append(f"{A.CYN}{'' * W}{A.RST}")
bw = min(24, W - 55)
if s.phase == "latency":
pct = s.done_count * 100 // max(1, s.total)
bx(f" {A.GRN}{A.RST} {A.BOLD}Latency{A.RST} [{self._bar(s.done_count, s.total, bw)}] {s.done_count}/{s.total} {pct}%")
elif s.alive_n > 0:
cut_info = f" {A.DIM}cut {s.latency_cut_n}{A.RST}" if s.latency_cut_n > 0 else ""
bx(f" {A.GRN}{A.RST} Latency {A.GRN}{s.alive_n} alive{A.RST} {A.DIM}{s.dead_n} dead{A.RST}{cut_info}")
else:
bx(f" {A.DIM}○ Latency waiting...{A.RST}")
for i, rc in enumerate(s.rounds):
rn = i + 1
lbl = f"Speed R{rn} ({rc.label}x{rc.keep})"
if s.cur_round == rn and s.phase.startswith("speed") and not s.finished:
pct = s.done_count * 100 // max(1, s.total)
bx(f" {A.GRN}{A.RST} {A.BOLD}{lbl:<18}{A.RST}[{self._bar(s.done_count, s.total, bw)}] {s.done_count}/{s.total} {pct}%")
elif s.cur_round > rn or (s.cur_round >= rn and s.finished):
bx(f" {A.GRN}{A.RST} {lbl:<18}{A.GRN}done{A.RST}")
else:
bx(f" {A.DIM}{lbl:<18}waiting...{A.RST}")
out.append(f"{A.CYN}{'' * W}{A.RST}")
parts = []
if s.alive_n > 0:
alats = [r.tls_ms for r in s.res.values() if r.alive and r.tls_ms > 0]
avg_lat = statistics.mean(alats) if alats else 0
parts.append(f"{A.GRN}{s.alive_n}{A.RST} alive")
parts.append(f"{A.RED}{s.dead_n}{A.RST} dead")
if avg_lat:
parts.append(f"{A.DIM}avg latency:{A.RST} {avg_lat:.0f}ms")
if s.best_speed > 0:
parts.append(f"{A.CYN}best:{A.RST} {s.best_speed:.2f} MB/s")
bx(" " + " ".join(parts) if parts else " ")
out.append(f"{A.CYN}{'' * W}{A.RST}")
hdr = f" {A.BOLD}{'#':>3} {'IP':<16} {'Dom':>3} {'Ping':>6} {'Conn':>6}"
for i, rc in enumerate(s.rounds):
hdr += f" {'R' + str(i + 1):>5}"
hdr += f" {'Colo':>4} {'Score':>5}{A.RST}"
bx(hdr)
sep = f" {'' * 3} {'' * 16} {'' * 3} {'' * 6} {'' * 6}"
for _ in s.rounds:
sep += f" {'' * 5}"
sep += f" {'' * 4} {'' * 5}"
bx(f"{A.DIM}{sep}{A.RST}")
results = sorted_all(s, self.sort)
total_results = len(results)
page = results[self.offset : self.offset + vis]
for rank, r in enumerate(page, self.offset + 1):
if not r.alive:
row = f" {A.DIM}{rank:>3} {r.ip:<16} {len(r.domains):>3} {A.RED}{'dead':>6}{A.RST}{A.DIM} {'':>6}"
for j in range(len(s.rounds)):
row += f" {'':>5}"
row += f" {'':>4} {A.RED}{'--':>5}{A.RST}"
bx(row)
continue
tcp = f"{r.tcp_ms:6.0f}" if r.tcp_ms > 0 else f"{A.DIM} -{A.RST}"
tls = f"{r.tls_ms:6.0f}" if r.tls_ms > 0 else f"{A.DIM} -{A.RST}"
row = f" {rank:>3} {r.ip:<16} {len(r.domains):>3} {tcp} {tls}"
for j in range(len(s.rounds)):
if j < len(r.speeds) and r.speeds[j] > 0:
row += f" {self._speed_str(r.speeds[j])}"
else:
row += f" {A.DIM} -{A.RST}"
if r.colo:
cl = f"{r.colo:>4}"
else:
cl = f"{A.DIM} -{A.RST}"
row += f" {cl} {self._cscore(r.score)}"
bx(row)
for _ in range(vis - len(page)):
bx("")
out.append(f"{A.CYN}{'' * W}{A.RST}")
if s.notify and time.monotonic() < s.notify_until:
bx(f" {A.GRN}{A.BOLD}{s.notify}{A.RST}")
elif s.finished:
sort_hint = f"sort:{A.BOLD}{self.sort}{A.RST}"
page_hint = f"{self.offset + 1}-{min(self.offset + vis, total_results)}/{total_results}"
ft = (
f" {A.CYN}[S]{A.RST} {sort_hint} "
f"{A.CYN}[E]{A.RST} Export "
f"{A.CYN}[A]{A.RST} ExportAll "
f"{A.CYN}[C]{A.RST} Configs "
f"{A.CYN}[D]{A.RST} Domains "
f"{A.CYN}[H]{A.RST} Help "
f"{A.CYN}[J/K]{A.RST}"
)
ft2 = (
f" Scroll {A.CYN}[N/P]{A.RST} Page ({page_hint}) "
f"{A.CYN}[B]{A.RST} Back "
f"{A.CYN}[Q]{A.RST} Quit"
)
bx(ft)
bx(ft2)
else:
bx(f" {A.DIM}{s.phase_label}... Press Ctrl+C to stop and export partial results{A.RST}")
out.append(f"{A.CYN}{'' * W}{A.RST}")
_w(A.HOME)
_w("\n".join(out) + "\n")
_fl()
def draw_domain_popup(self, r: Result):
"""Show domains for the selected IP."""
_w(A.CLR)
cols, rows = term_size()
vis = min(len(r.domains), rows - 10)
lines = []
lines.append(f"{A.CYN}{'' * (cols - 2)}{A.RST}")
lines.append(draw_box_line(f" {A.BOLD}Domains for {r.ip} ({len(r.domains)} total){A.RST}", cols))
ping_s = f"{r.tcp_ms:.0f}ms" if r.tcp_ms > 0 else "-"
conn_s = f"{r.tls_ms:.0f}ms" if r.tls_ms > 0 else "-"
lines.append(draw_box_line(f" {A.DIM}Score: {r.score:.1f} | Ping: {ping_s} | Conn: {conn_s}{A.RST}", cols))
lines.append(draw_box_sep(cols))
for d in r.domains[:vis]:
lines.append(draw_box_line(f" {d}", cols))
if len(r.domains) > vis:
lines.append(draw_box_line(f" {A.DIM}...and {len(r.domains) - vis} more{A.RST}", 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()
_wait_any_key()
_w(A.CLR) # clear before dashboard redraws
def draw_config_popup(self, r: Result):
"""Show all VLESS/VMess URIs for the selected IP."""
_w(A.CLR)
cols, rows = term_size()
lines = []
lines.append(f"{A.CYN}{'' * (cols - 2)}{A.RST}")
lines.append(draw_box_line(f" {A.BOLD}Configs for {r.ip} ({len(r.uris)} URIs){A.RST}", cols))
ping_s = f"{r.tcp_ms:.0f}ms" if r.tcp_ms > 0 else "-"
conn_s = f"{r.tls_ms:.0f}ms" if r.tls_ms > 0 else "-"
speed_s = f"{r.best_mbps:.1f} MB/s" if r.best_mbps > 0 else "-"
lines.append(draw_box_line(
f" {A.DIM}Score: {r.score:.1f} | Ping: {ping_s} | Conn: {conn_s} | Speed: {speed_s}{A.RST}", cols
))
lines.append(draw_box_sep(cols))
if r.uris:
max_show = rows - 10
for i, uri in enumerate(r.uris[:max_show]):
# Truncate long URIs to fit terminal width
tag = f" {A.CYN}{i+1}.{A.RST} "
max_uri = cols - 8
display = uri if len(uri) <= max_uri else uri[:max_uri - 3] + "..."
lines.append(draw_box_line(f"{tag}{A.GRN}{display}{A.RST}", cols))
if len(r.uris) > max_show:
lines.append(draw_box_line(f" {A.DIM}...and {len(r.uris) - max_show} more{A.RST}", cols))
else:
lines.append(draw_box_line(f" {A.DIM}No VLESS/VMess URIs stored for this IP{A.RST}", cols))
lines.append(draw_box_line(f" {A.DIM}(only available when loaded from URIs or subscriptions){A.RST}", 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()
_wait_any_key()
_w(A.CLR)
def draw_help_popup(self):
"""Show keybinding help + column explanations overlay."""
_w(A.CLR)
cols, rows = term_size()
W = min(64, cols - 4)
lines = []
lines.append(f" {A.CYN}{'=' * W}{A.RST}")
lines.append(f" {A.BOLD}{A.WHT} Keyboard Shortcuts{A.RST}")
lines.append(f" {A.CYN}{'-' * W}{A.RST}")
help_items = [
("S", "Cycle sort order: score / latency / speed"),
("E", "Export results (CSV + top N configs)"),
("A", "Export ALL configs sorted best to worst"),
("C", "View VLESS/VMess URIs for an IP (enter rank #)"),
("D", "View domains for an IP (enter rank #)"),
("J / K", "Scroll down / up one row"),
("N / P", "Page down / up"),
("B", "Back to main menu (new scan)"),
("H", "Show this help screen"),
("Q", "Quit (results auto-saved on exit)"),
]
for key, desc in help_items:
lines.append(f" {A.CYN}{key:<10}{A.RST} {desc}")
lines.append("")
lines.append(f" {A.CYN}{'=' * W}{A.RST}")
lines.append(f" {A.BOLD}{A.WHT} Column Guide{A.RST}")
lines.append(f" {A.CYN}{'-' * W}{A.RST}")
col_items = [
("#", "Rank (sorted by current sort order)"),
("IP", "Cloudflare edge IP address"),
("Dom", "How many domains share this IP"),
("Ping", "TCP connect time in ms (like ping)"),
("Conn", "Full connection time in ms (TCP + TLS handshake)"),
("R1,R2..", "Download speed per round (MB/s or KB/s)"),
("Colo", "CF datacenter code (e.g. FRA, IAH, MRS)"),
("Score", "Combined score (0-100, higher = better)"),
]
for key, desc in col_items:
lines.append(f" {A.CYN}{key:<10}{A.RST} {desc}")
lines.append("")
lines.append(f" {A.DIM}Score = Conn latency (35%) + speed (50%) + TTFB (15%){A.RST}")
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://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}")
_w("\n".join(lines) + "\n")
_fl()
_wait_any_key()
_w(A.CLR) # clear before dashboard redraws
def handle(self, key: str) -> Optional[str]:
sorts = ["score", "latency", "speed"]
if key == "s":
idx = sorts.index(self.sort) if self.sort in sorts else 0
self.sort = sorts[(idx + 1) % len(sorts)]
elif key in ("j", "down"):
self.offset = min(self.offset + 1, max(0, len(sorted_all(self.st, self.sort)) - 3))
elif key in ("k", "up"):
self.offset = max(0, self.offset - 1)
elif key == "n":
# page down
_, rows = term_size()
page = max(3, rows - 18 - len(self.st.rounds))
self.offset = min(self.offset + page, max(0, len(sorted_all(self.st, self.sort)) - 3))
elif key == "p":
# page up
_, rows = term_size()
page = max(3, rows - 18 - len(self.st.rounds))
self.offset = max(0, self.offset - page)
elif key == "e":
return "export"
elif key == "a":
return "export-all"
elif key == "c":
return "configs"
elif key == "d":
return "domains"
elif key == "h":
return "help"
elif key == "b":
return "back"
elif key in ("q", "ctrl-c"):
return "quit"
return None
def save_csv(st: State, path: str, sort_by: str = "score"):
results = sorted_alive(st, sort_by)
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
hdr = ["Rank", "IP", "Domains", "Domain_Count", "Ping_ms", "Conn_ms", "TTFB_ms"]
for i, rc in enumerate(st.rounds):
hdr.append(f"R{i + 1}_{rc.label}_MBps")
hdr += ["Best_MBps", "Colo", "Score", "Error"]
w.writerow(hdr)
for rank, r in enumerate(results, 1):
row = [
rank,
r.ip,
"|".join(r.domains[:5]),
len(r.domains),
f"{r.tcp_ms:.1f}" if r.tcp_ms > 0 else "",
f"{r.tls_ms:.1f}" if r.tls_ms > 0 else "",
f"{r.ttfb_ms:.1f}" if r.ttfb_ms > 0 else "",
]
for i in range(len(st.rounds)):
row.append(
f"{r.speeds[i]:.3f}"
if i < len(r.speeds) and r.speeds[i] > 0
else ""
)
row += [
f"{r.best_mbps:.3f}" if r.best_mbps > 0 else "",
r.colo,
f"{r.score:.1f}",
r.error,
]
w.writerow(row)
def save_configs(st: State, path: str, top: int = 50, sort_by: str = "score"):
"""Save top configs. Use top=0 for ALL configs sorted best to worst."""
results = sorted_alive(st, sort_by)
has_uris = any(r.uris for r in results)
limit = top if top > 0 else len(results)
with open(path, "w", encoding="utf-8") as f:
n = 0
for r in results:
if n >= limit:
break
if has_uris:
for uri in r.uris:
f.write(uri + "\n")
n += 1
if n >= limit:
break
else:
# JSON input: write IP and domains as a reference list
doms = ", ".join(r.domains[:3])
extra = f" (+{len(r.domains) - 3} more)" if len(r.domains) > 3 else ""
f.write(f"{r.ip} # score={r.score:.1f} domains={doms}{extra}\n")
n += 1
def save_all_configs_sorted(st: State, path: str, sort_by: str = "score"):
"""Save ALL raw configs (every URI) sorted by their IP's score, best to worst."""
results = sorted_alive(st, sort_by)
dead = [r for r in st.res.values() if not r.alive]
has_uris = any(r.uris for r in results)
with open(path, "w", encoding="utf-8") as f:
for r in results:
if has_uris:
for uri in r.uris:
f.write(uri + "\n")
else:
doms = ", ".join(r.domains[:3])
extra = f" (+{len(r.domains) - 3} more)" if len(r.domains) > 3 else ""
f.write(f"{r.ip} # score={r.score:.1f} domains={doms}{extra}\n")
for r in dead:
if has_uris:
for uri in r.uris:
f.write(uri + "\n")
else:
doms = ", ".join(r.domains[:3])
f.write(f"{r.ip} # DEAD domains={doms}\n")
RESULTS_DIR = "results"
def _results_path(filename: str) -> str:
"""Return path inside the results/ directory, creating it if needed."""
os.makedirs(RESULTS_DIR, exist_ok=True)
return os.path.join(RESULTS_DIR, filename)
def do_export(
st: State, base_path: str, sort_by: str = "score", top: int = 50,
output_csv: str = "", output_configs: str = "",
) -> Tuple[str, str, str]:
stem = os.path.basename(base_path).rsplit(".", 1)[0] if base_path else "scan"
csv_path = output_csv if output_csv else _results_path(stem + "_results.csv")
if output_configs:
cfg_path = output_configs
elif top <= 0:
cfg_path = _results_path(stem + "_all_sorted.txt")
else:
cfg_path = _results_path(stem + f"_top{top}.txt")
full_path = _results_path(stem + "_full_sorted.txt")
save_csv(st, csv_path, sort_by)
save_configs(st, cfg_path, top, sort_by)
save_all_configs_sorted(st, full_path, sort_by)
st.saved = True
return csv_path, cfg_path, full_path
async def _refresh_loop(dash: Dashboard, st: State):
while not st.finished:
try:
dash.draw()
except Exception:
pass
await asyncio.sleep(0.3)
async def run_scan(st: State, workers: int, speed_workers: int, timeout: float, speed_timeout: float):
"""Run the scan phases with dynamic round sizing."""
try:
os.makedirs("results", exist_ok=True)
with open(DEBUG_LOG, "w") as f:
f.write(f"=== Scan started {time.strftime('%Y-%m-%d %H:%M:%S')} mode={st.mode} ===\n")
except Exception:
pass
st.start_time = time.monotonic()
if not st.interrupted:
await phase1(st, workers, timeout)
if st.interrupted or st.alive_n == 0:
st.finished = True
calc_scores(st)
return
preset = PRESETS.get(st.mode, PRESETS["normal"])
alive = sorted(
(ip for ip, r in st.res.items() if r.alive),
key=lambda ip: st.res[ip].tls_ms,
)
cut_pct = preset.get("latency_cut", 0)
if cut_pct > 0 and len(alive) > 50:
cut_n = max(1, int(len(alive) * cut_pct / 100))
alive = alive[:-cut_n]
st.latency_cut_n = cut_n
_dbg(f"=== Latency cut: removed bottom {cut_pct}% = {cut_n} IPs, {len(alive)} remaining ===")
if not st.rounds:
st.rounds = build_dynamic_rounds(st.mode, len(alive))
_dbg(f"=== Dynamic rounds: {[(r.label, r.keep) for r in st.rounds]} ===")
if not st.interrupted and st.rounds:
rlim = CFRateLimiter()
cands = list(alive)
cdn_host = SPEED_HOST
cdn_path = "" # _dl_one uses default
for i, rc in enumerate(st.rounds):
if st.interrupted:
break
st.cur_round = i + 1
st.phase = f"speed_r{i + 1}"
actual_count = min(rc.keep, len(cands))
st.phase_label = f"Speed R{i + 1} ({rc.label} x {actual_count})"
_dbg(f"=== Round R{i+1}: {rc.size}B x {actual_count} IPs, workers={speed_workers}, timeout={speed_timeout}s, budget={rlim.BUDGET - rlim.count} left ===")
if i > 0:
calc_scores(st)
cands = sorted(cands, key=lambda ip: st.res[ip].score, reverse=True)
cands = cands[: rc.keep]
await phase2_round(
st, rc, cands, speed_workers, speed_timeout,
rlim=rlim, cdn_host=cdn_host, cdn_path=cdn_path,
)
calc_scores(st)
st.finished = True
calc_scores(st)
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):
if getattr(args, "input", None):
input_method, input_value = "template", f"{args.template}|||{args.input}"
else:
print("Error: --template requires -i (address list file)")
return
elif getattr(args, "find_clean", False):
input_method, input_value = "find_clean", ""
elif getattr(args, "input", None):
input_method, input_value = "file", args.input
while True: # outer loop: back returns here
interactive = input_method is None
while True:
if input_method is None:
pick = tui_pick_file()
if not pick:
_w(A.SHOW)
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:
_w(A.SHOW)
return
if result[0] == "__back__":
input_method = None
input_value = None
continue
input_method, input_value = result
mode = args.mode
if not getattr(args, "_mode_set", False) and interactive:
picked = tui_pick_mode()
if not picked:
_w(A.SHOW)
return
if picked == "__back__":
input_method = None
input_value = None
continue
mode = picked
break
st = State()
st.mode = mode
st.top = args.top
if args.rounds:
st.rounds = parse_rounds_str(args.rounds)
elif args.skip_download:
st.rounds = []
# Determine display label for loading screen
if input_method == "sub":
load_label = input_value.split("/")[-1][:40] or "subscription"
elif input_method == "template":
parts = input_value.split("|||", 1)
load_label = os.path.basename(parts[1]) if len(parts) > 1 else "template"
else:
load_label = os.path.basename(input_value)
_w(A.CLR + A.HOME)
cols, _ = term_size()
lines = draw_menu_header(cols)
lines.append(draw_box_line(f" {A.BOLD}Starting scan...{A.RST}", cols))
lines.append(draw_box_line("", cols))
lines.append(draw_box_line(f" {A.CYN}>{A.RST} Loading {load_label}...", cols))
lines.append(draw_box_bottom(cols))
_w("\n".join(lines) + "\n")
_fl()
# Load configs based on input method
if input_method == "sub":
st.configs = fetch_sub(input_value)
st.input_file = input_value
elif input_method == "template":
tpl_uri, addr_path = input_value.split("|||", 1)
addrs = load_addresses(addr_path)
st.configs = generate_from_template(tpl_uri, addrs)
st.input_file = f"{addr_path} ({len(addrs)} addresses)"
else:
st.configs = load_input(input_value)
st.input_file = input_value
if not st.configs:
_w(A.SHOW)
print(f"No configs found in {st.input_file}")
return
_w(A.CLR + A.HOME)
lines = draw_menu_header(cols)
lines.append(draw_box_line(f" {A.BOLD}Starting scan...{A.RST}", cols))
lines.append(draw_box_line("", cols))
lines.append(draw_box_line(f" {A.GRN}OK{A.RST} Loaded {len(st.configs)} configs", cols))
lines.append(draw_box_line("", cols))
_w("\n".join(lines) + "\n")
_fl()
st.phase = "dns"
st.phase_label = "Resolving DNS"
try:
await resolve_all(st)
except Exception as e:
_w(A.SHOW + "\n")
print(f"DNS resolution error: {e}")
return
if not st.ips:
_w(A.SHOW + "\n")
print("No IPs resolved — check network or config addresses.")
return
dash = Dashboard(st)
refresh = asyncio.create_task(_refresh_loop(dash, st))
scan_task = asyncio.ensure_future(
run_scan(st, args.workers, args.speed_workers, args.timeout, args.speed_timeout)
)
old_sigint = signal.getsignal(signal.SIGINT)
def _sig(sig, frame):
st.interrupted = True
st.finished = True
scan_task.cancel()
signal.signal(signal.SIGINT, _sig)
try:
await scan_task
except asyncio.CancelledError:
st.interrupted = True
st.finished = True
calc_scores(st)
# Restore original SIGINT so Ctrl+C works in post-scan loop
signal.signal(signal.SIGINT, old_sigint)
if refresh:
refresh.cancel()
try:
await refresh
except asyncio.CancelledError:
pass
try:
csv_p, cfg_p, full_p = do_export(st, input_value, dash.sort, st.top)
st.notify = f"Saved to results/ folder"
except Exception as e:
csv_p = cfg_p = full_p = ""
st.notify = f"Export error: {e}"
st.notify_until = time.monotonic() + 5
dash.draw()
go_back = False
try:
while True:
key = _read_key_nb(0.1)
if key is None:
# refresh notification timeout
if st.notify and time.monotonic() >= st.notify_until:
st.notify = ""
dash.draw()
continue
act = dash.handle(key)
if act == "quit":
break
elif act == "back":
# show save summary and go to main menu
_w(A.CLR)
save_lines = [
f" {A.CYN}{'=' * 50}{A.RST}",
f" {A.BOLD}{A.WHT} Results saved:{A.RST}",
f" {A.CYN}{'-' * 50}{A.RST}",
f" {A.GRN}CSV:{A.RST} {csv_p}",
f" {A.GRN}Top:{A.RST} {cfg_p}",
f" {A.GRN}Full:{A.RST} {full_p}",
f" {A.CYN}{'=' * 50}{A.RST}",
"",
f" {A.DIM}Press any key to go to main menu...{A.RST}",
]
_w("\n".join(save_lines) + "\n")
_fl()
_wait_any_key()
go_back = True
break
elif act == "export":
try:
csv_p, cfg_p, full_p = do_export(st, input_value, dash.sort, st.top)
st.notify = f"Exported to results/ folder"
except Exception as e:
st.notify = f"Export error: {e}"
st.notify_until = time.monotonic() + 4
elif act == "export-all":
try:
csv_p, cfg_p, full_p = do_export(st, input_value, dash.sort, 0)
st.notify = f"Exported ALL to results/ folder"
except Exception as e:
st.notify = f"Export error: {e}"
st.notify_until = time.monotonic() + 4
elif act == "configs":
results = sorted_all(st, dash.sort)
if results:
n = _prompt_number(f"{A.CYN}Enter rank # to view configs (1-{len(results)}):{A.RST} ", len(results))
if n is not None:
dash.draw_config_popup(results[n - 1])
elif act == "domains":
results = sorted_all(st, dash.sort)
if results:
n = _prompt_number(f"{A.CYN}Enter rank # to view domains (1-{len(results)}):{A.RST} ", len(results))
if n is not None:
dash.draw_domain_popup(results[n - 1])
elif act == "help":
dash.draw_help_popup()
dash.draw()
except (KeyboardInterrupt, EOFError, OSError):
pass
if go_back:
# reset for next run — clear CLI input so file picker shows
args.input = None
args.sub = None
args.template = None
args._mode_set = False
input_method = None
input_value = None
continue
_w(A.SHOW + "\n")
_fl()
print(f"Results saved to {RESULTS_DIR}/ folder")
break
async def run_headless(args):
"""Headless mode (--no-tui)."""
st = State()
st.input_file = args.input
st.mode = args.mode
if args.rounds:
st.rounds = parse_rounds_str(args.rounds)
elif args.skip_download:
st.rounds = []
print(f"CF Config Scanner v{VERSION}")
st.configs, src = load_configs_from_args(args)
print(f"Loading: {src}")
print(f"Loaded {len(st.configs)} configs")
if not st.configs:
return
print("Resolving DNS...")
await resolve_all(st)
print(f" {len(st.ips)} unique IPs")
if not st.ips:
return
scan_task = asyncio.ensure_future(
run_scan(st, args.workers, args.speed_workers, args.timeout, args.speed_timeout)
)
old_sigint = signal.getsignal(signal.SIGINT)
def _sig(sig, frame):
st.interrupted = True
st.finished = True
scan_task.cancel()
signal.signal(signal.SIGINT, _sig)
try:
await scan_task
except asyncio.CancelledError:
st.interrupted = True
st.finished = True
calc_scores(st)
print("\n Interrupted! Exporting partial results...")
signal.signal(signal.SIGINT, old_sigint)
results = sorted_alive(st, "score")
elapsed = _fmt_elapsed(time.monotonic() - st.start_time)
print(f"\nDone in {elapsed}. {st.alive_n} alive IPs.\n")
print(f"{'=' * 95}")
hdr = f"{'#':>4} {'IP':<16} {'Dom':>4} {'Ping ms':>7} {'Conn ms':>7}"
for i in range(len(st.rounds)):
hdr += f" {'R' + str(i + 1) + ' MB/s':>9}"
hdr += f" {'Colo':>5} {'Score':>6}"
print(hdr)
print("=" * 95)
for rank, r in enumerate(results[:50], 1):
tcp = f"{r.tcp_ms:7.1f}" if r.tcp_ms > 0 else " -"
tls = f"{r.tls_ms:7.1f}" if r.tls_ms > 0 else " -"
row = f"{rank:>4} {r.ip:<16} {len(r.domains):>4} {tcp} {tls}"
for j in range(len(st.rounds)):
if j < len(r.speeds) and r.speeds[j] > 0:
row += f" {r.speeds[j]:>9.2f}"
else:
row += " -"
cl = f"{r.colo:>5}" if r.colo else " -"
sc = f"{r.score:>6.1f}" if r.score > 0 else " -"
row += f" {cl} {sc}"
print(row)
try:
csv_p, cfg_p, full_p = do_export(
st, args.input or "scan", top=args.top,
output_csv=getattr(args, "output", "") or "",
output_configs=getattr(args, "output_configs", "") or "",
)
print(f"\nResults saved:")
print(f" CSV: {csv_p}")
print(f" Configs: {cfg_p}")
print(f" Full: {full_p}")
except Exception as e:
print(f"\nError saving results: {e}")
async def run_headless_clean(args):
"""Headless clean IP finder (--find-clean --no-tui)."""
scan_cfg = CLEAN_MODES.get(getattr(args, "clean_mode", "normal"), CLEAN_MODES["normal"])
subnets = CF_SUBNETS
if getattr(args, "subnets", None):
if os.path.isfile(args.subnets):
with open(args.subnets, encoding="utf-8") as f:
subnets = [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")]
else:
subnets = [s.strip() for s in args.subnets.split(",") if s.strip()]
ports = scan_cfg.get("ports", [443])
print(f"CF Config Scanner v{VERSION} — Clean IP Finder")
print(f"Ranges: {len(subnets)} | Sample: {scan_cfg['sample'] or 'all'} | Workers: {scan_cfg['workers']} | Ports: {', '.join(str(p) for p in ports)}")
ips = generate_cf_ips(subnets, scan_cfg["sample"])
total_probes = len(ips) * len(ports)
print(f"Scanning {len(ips):,} IPs × {len(ports)} port(s) = {total_probes:,} probes...")
cs = CleanScanState()
start = time.monotonic()
scan_task = asyncio.ensure_future(
scan_clean_ips(
ips, workers=scan_cfg["workers"], timeout=3.0,
validate=scan_cfg["validate"], cs=cs, ports=ports,
)
)
old_sigint = signal.getsignal(signal.SIGINT)
def _sig(sig, frame):
cs.interrupted = True
scan_task.cancel()
signal.signal(signal.SIGINT, _sig)
last_pct = -1
try:
while not scan_task.done():
pct = cs.done * 100 // max(1, cs.total)
if pct != last_pct and pct % 5 == 0:
print(f" {pct}% ({cs.done:,}/{cs.total:,}) found {cs.found:,} clean")
last_pct = pct
await asyncio.sleep(1)
except (asyncio.CancelledError, Exception):
pass
finally:
signal.signal(signal.SIGINT, old_sigint)
try:
results = await scan_task
except (asyncio.CancelledError, Exception):
results = sorted(cs.all_results or cs.results, key=lambda x: x[1])
elapsed = _fmt_elapsed(time.monotonic() - start)
print(f"\nDone in {elapsed}. Found {len(results):,} clean IPs.\n")
print(f"{'='*50}")
print(f"{'#':>4} {'Address':<22} {'Latency':>8}")
print(f"{'='*50}")
for i, (ip, lat) in enumerate(results[:30]):
print(f"{i+1:>4} {ip:<22} {lat:>6.0f}ms")
if len(results) > 30:
print(f" ...and {len(results)-30:,} more")
if results:
try:
os.makedirs(RESULTS_DIR, exist_ok=True)
path = os.path.abspath(_results_path("clean_ips.txt"))
with open(path, "w", encoding="utf-8") as f:
for ip, lat in results:
f.write(f"{ip}\n")
print(f"\nSaved {len(results):,} IPs to {path}")
except Exception as e:
print(f"\nSave error: {e}")
path = ""
else:
print("\nNo clean IPs found. Nothing saved.")
path = ""
# If --template also given, proceed to speed test
if getattr(args, "template", None) and results:
print(f"\nContinuing to speed test with template...")
addrs = [ip for ip, _ in results]
configs = generate_from_template(args.template, addrs)
if configs:
args.input = path
st = State()
st.input_file = f"clean ({len(results)} IPs)"
st.mode = args.mode
st.configs = configs
if args.rounds:
st.rounds = parse_rounds_str(args.rounds)
elif args.skip_download:
st.rounds = []
print(f"Generated {len(configs)} configs")
print("Resolving DNS...")
await resolve_all(st)
print(f" {len(st.ips)} unique IPs")
if st.ips:
start2 = time.monotonic()
scan2 = asyncio.ensure_future(
run_scan(st, args.workers, args.speed_workers, args.timeout, args.speed_timeout)
)
old2 = signal.getsignal(signal.SIGINT)
def _sig2(sig, frame):
st.interrupted = True
st.finished = True
scan2.cancel()
signal.signal(signal.SIGINT, _sig2)
try:
await scan2
except asyncio.CancelledError:
st.interrupted = True
st.finished = True
calc_scores(st)
signal.signal(signal.SIGINT, old2)
alive_results = sorted_alive(st, "score")
elapsed2 = _fmt_elapsed(time.monotonic() - start2)
print(f"\nSpeed test done in {elapsed2}. {st.alive_n} alive.")
print(f"{'='*80}")
for rank, r in enumerate(alive_results[:20], 1):
spd = f"{r.best_mbps:.2f}" if r.best_mbps > 0 else " -"
lat_s = f"{r.tls_ms:.0f}" if r.tls_ms > 0 else " -"
print(f"{rank:>3} {r.ip:<16} {lat_s:>6}ms {spd:>8} MB/s score={r.score:.1f}")
try:
csv_p, cfg_p, full_p = do_export(st, path, top=args.top)
print(f"\nSaved: {csv_p} | {cfg_p} | {full_p}")
except Exception as e:
print(f"Export error: {e}")
def main():
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
p = argparse.ArgumentParser(
description="CF Config Scanner - test VLESS configs for latency + download speed",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""Run with no arguments for interactive TUI.
Modes (sort by latency first, then speed-test the best):
quick Cut 50%% latency, 1MB x100 -> 5MB x20 (~200 MB, ~2-3 min)
normal Cut 40%% latency, 1MB x200 -> 5MB x50 -> 20MB x20 (~850 MB, ~5-10 min)
thorough Cut 15%% latency, 5MB xALL -> 25MB x150 -> 100MB x50 (~8-15 GB, ~30-60 min)
Examples:
%(prog)s Interactive TUI
%(prog)s -i configs.txt TUI with file
%(prog)s --sub https://example.com/sub.txt Fetch from subscription URL
%(prog)s --template "vless://UUID@{ip}:443?..." -i addrs.json Generate from template
%(prog)s -i configs.txt --mode quick Quick scan
%(prog)s -i configs.txt --top 0 Export ALL sorted
%(prog)s -i configs.txt --no-tui -o results.csv Headless
%(prog)s --find-clean --no-tui Find clean CF IPs (headless)
%(prog)s --find-clean --no-tui --template "vless://..." Find + speed test
""",
)
p.add_argument("-i", "--input", help="Input file (VLESS URIs or domains.json)")
p.add_argument("--sub", help="Subscription URL (fetches VLESS URIs from URL)")
p.add_argument("--template", help="Base VLESS URI template (use with -i address list)")
p.add_argument("-m", "--mode", choices=["quick", "normal", "thorough"], default="normal")
p.add_argument("--rounds", help='Custom rounds, e.g. "1MB:200,5MB:50,20MB:20"')
p.add_argument("-w", "--workers", type=int, default=LATENCY_WORKERS, help="Latency workers")
p.add_argument("--speed-workers", type=int, default=SPEED_WORKERS, help="Download workers")
p.add_argument("--timeout", type=float, default=LATENCY_TIMEOUT, help="Latency timeout (s)")
p.add_argument("--speed-timeout", type=float, default=SPEED_TIMEOUT, help="Download timeout (s)")
p.add_argument("--skip-download", action="store_true", help="Latency only")
p.add_argument("--top", type=int, default=50, help="Export top N configs (0 = ALL sorted best to worst)")
p.add_argument("--no-tui", action="store_true", help="Plain text output")
p.add_argument("-o", "--output", help="CSV output path (headless)")
p.add_argument("--output-configs", help="Save top VLESS URIs (headless)")
p.add_argument("--find-clean", action="store_true", help="Find clean Cloudflare IPs")
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:
if not args.input and not args.sub and not args.template:
p.error("--input, --sub, or --template is required in --no-tui mode")
asyncio.run(run_headless(args))
else:
asyncio.run(run_tui(args))
except KeyboardInterrupt:
pass
finally:
_w(A.SHOW + "\n")
_fl()
if __name__ == "__main__":
main()