8898 lines
338 KiB
Python
8898 lines
338 KiB
Python
#!/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()
|