Skip to content

Commit 3d43624

Browse files
Add files via upload
1 parent ed88901 commit 3d43624

5 files changed

Lines changed: 144 additions & 95 deletions

File tree

examples/04_connection_tester.py

Lines changed: 22 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
import os
44
import sys
5-
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
65
from pathlib import Path
7-
from python_v2ray.config_parser import parse_uri, XrayConfigBuilder
8-
from python_v2ray.tester import ConnectionTester
6+
7+
# * This ensures the script can find our local library files
8+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
9+
910
from python_v2ray.downloader import BinaryDownloader
11+
from python_v2ray.tester import ConnectionTester
12+
from python_v2ray.config_parser import parse_uri
1013

1114
def main():
1215
"""
13-
* Runs a series of tests with different URI types.
16+
* Demonstrates how to use the high-speed, multi-client connection tester.
1417
"""
1518
project_root = Path(__file__).parent.parent
1619

@@ -23,45 +26,33 @@ def main():
2326

2427
vendor_dir = project_root / "vendor"
2528
core_engine_dir = project_root / "core_engine"
26-
# Set to None to disable fragmentation
27-
fragment_settings = {
28-
"packets": "tlshello",
29-
"length": "10-30",
30-
"interval": "1-5"
31-
}
29+
3230

3331
test_uris = [
3432
"vless://8b63cf90-830c-4fd8-a911-9e84fd7a5898@172.67.74.104:443?path=%2F%3FJoin---i10VPN---Join---i10VPN---Join---i10VPN---Join---i10VPN%3Fed%3D512&security=tls&encryption=none&alpn=http%2F1.1&host=s.s.google.com.b5r.ir.&fp=chrome&type=ws&sni=b5r.ir#vless",
3533
"hy2://dongtaiwang.com@208.87.243.187:22222/?insecure=1&sni=www.bing.com#hy2",
3634
"hy2://dongtaiwang.com@51.159.111.32:5355/?insecure=1&sni=www.bing.com#hy2",
3735
"hy2://7GEEGxAfgQaVPQX0PGk7lIuj3I@158.41.110.234:10820/?insecure=1&sni=bing.com#hy2",
38-
"vmess://eyJhZGQiOiJucG1qcy5jb20iLCJhaWQiOiIwIiwiYWxwbiI6IiIsImVjaENvbmZpZ0xpc3QiOiIiLCJlY2hGb3JjZVF1ZXJ5IjoiIiwiZWNoU2VydmVyS2V5cyI6IiIsImZha2Vob3N0X2RvbWFpbiI6IiIsImZwIjoiIiwiaG9zdCI6Im5hc25ldC0xMjgxNDAxMTIyMy5raGFzdGVobmFiYXNoaS5jb20iLCJpZCI6Im5hc25ldCIsImludGVydmFsIjoiIiwibGVuZ3RoIjoiIiwibXV4IjoiIiwibXV4Q29uY3VycmVuY3kiOiIiLCJuZXQiOiJ3cyIsInBhY2tldHMiOiIiLCJwYXRoIjoiL05BU05FVC9jZG4iLCJwb3J0IjoiODA4MCIsInBzIjoiXHUwMDNlXHUwMDNlQEZyZWFrQ29uZmlnOjpERSIsInNjeSI6ImF1dG8iLCJzbmkiOiIiLCJ0bHMiOiIiLCJ0eXBlIjoiIiwidiI6IjIifQ==",
39-
"trojan://2ee85121-31de-4581-a492-eb00f606e392@198.46.152.83:443?mux=&security=tls&headerType=none&type=tcp&muxConcurrency=-1&sni=sj3.freeguard.org#20%F0%9F%8E%A1%40oneclickvpnkeys",
40-
"ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpmOGY3YUN6Y1BLYnNGOHAz@185.213.23.226:990#%3E%3E%40free4allVPN%3A%3ANO",
36+
"vmess://eyJhZGQiOiI0NS4xMjguNTQuODQiLCJhaWQiOiIwIiwiYWxwbiI6IiIsImVjaENvbmZpZ0xpc3QiOiIiLCJlY2hGb3JjZVF1ZXJ5IjoiIiwiZWNoU2VydmVyS2V5cyI6IiIsImZha2Vob3N0X2RvbWFpbiI6IiIsImZwIjoiIiwiaG9zdCI6IiIsImlkIjoiNzJmZWI4MzMtNDE5Ni00YTcwLWEzZjUtYWViMDExZjdkNTM0IiwiaW50ZXJ2YWwiOiIiLCJsZW5ndGgiOiIiLCJtdXgiOiIiLCJtdXhDb25jdXJyZW5jeSI6IiIsIm5ldCI6InRjcCIsInBhY2tldHMiOiIiLCJwYXRoIjoiIiwicG9ydCI6IjM3MzEzIiwicHMiOiIwODQg4pyC77iPLfCfh6bwn4e/QVotKEBHaGV5Y2hpQW1vb3plc2gpIiwic2N5IjoiYXV0byIsInNuaSI6IiIsInRscyI6IiIsInR5cGUiOiJub25lIiwidiI6IjIifQ==",
37+
"trojan://2ee85121-31de-4581-a492-eb00f606e392@198.46.152.83:443?mux=&security=tls&headerType=none&type=tcp&muxConcurrency=-1&sni=sj3.freeguard.org#trojan",
38+
"ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpmOGY3YUN6Y1BLYnNGOHAz@185.213.23.226:990#ss",
4139
# ... Add your other real URIs here ...
4240
]
4341

42+
fragment_settings = { "packets": "tlshello", "length": "10-30", "interval": "1-5"}
43+
4444
print("* Parsing all URIs...")
45-
outbounds_to_test = []
46-
builder = XrayConfigBuilder()
47-
48-
for uri in test_uris:
49-
if "YOUR_" in uri or "..." in uri:
50-
print(f"! Skipping placeholder URI: {uri}")
51-
continue
52-
params = parse_uri(uri)
53-
if params:
54-
outbound_dict = builder.build_outbound_from_params(params)
55-
outbounds_to_test.append(outbound_dict)
56-
57-
if not outbounds_to_test:
45+
parsed_configs = [p for p in (parse_uri(uri) for uri in test_uris) if p]
46+
47+
if not parsed_configs:
5848
print("\n! No valid URIs found to test. Please edit the 'test_uris' list in the script.")
5949
return
6050

61-
print(f"\n* Preparing to test {len(outbounds_to_test)} configurations concurrently...")
51+
print(f"\n* Preparing to test {len(parsed_configs)} configurations concurrently...")
6252

6353
tester = ConnectionTester(vendor_path=str(vendor_dir), core_engine_path=str(core_engine_dir))
64-
results = tester.test_outbounds(outbounds_to_test)
54+
55+
results = tester.test_uris(parsed_configs, fragment_config=fragment_settings)
6556

6657
print("\n" + "="*20 + " TEST RESULTS " + "="*20)
6758
if results:
@@ -71,12 +62,12 @@ def main():
7162
ping = result.get('ping_ms', -1)
7263
status = result.get('status', 'error')
7364
if status == 'success':
74-
print(f"* Tag: {tag:<20} | Ping: {ping:>4} ms | Status: {status}")
65+
print(f"* Tag: {tag:<30} | Ping: {ping:>4} ms | Status: {status}")
7566
else:
76-
print(f"! Tag: {tag:<20} | Ping: {ping:>4} ms | Status: {status}")
67+
print(f"! Tag: {tag:<30} | Ping: {ping:>4} ms | Status: {status}")
7768
else:
7869
print("! No results received from the tester.")
79-
print("="*54)
70+
print("="*64)
8071

8172
if __name__ == "__main__":
8273
main()

python_v2ray/config_parser.py

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,50 +61,55 @@ class ConfigParams:
6161
hy2_obfs_password: Optional[str] = ""
6262

6363

64-
# ! =======================================================================
65-
# ! === STEP 2: THE URI PARSING ENGINE ===
66-
# ! =======================================================================
67-
6864
def _parse_query_params(query: str) -> Dict[str, str]:
6965
"""* A utility to parse URL query parameters into a dictionary."""
7066
return {k: v[0] for k, v in urllib.parse.parse_qs(query).items()}
71-
7267
def parse_uri(config_uri: str) -> Optional[ConfigParams]:
7368
"""
7469
* This is the main parsing engine. It delegates the parsing to
75-
* protocol-specific helper functions.
70+
* protocol-specific helper functions and validates the core components.
7671
"""
7772
try:
7873
uri = urllib.parse.unquote(config_uri).strip()
74+
7975
raw_tag = uri.split("#", 1)[1] if len(uri.split("#", 1)) > 1 else "Untitled"
80-
clean_tag = re.sub(r'[^a-zA-Z0-9_-]', '_', raw_tag)
81-
tag = clean_tag if clean_tag else "proxy"
76+
tag = re.sub(r'[^a-zA-Z0-9_.-]', '_', raw_tag) or "proxy"
77+
8278
protocol = uri.split("://")[0]
8379

80+
if "@" not in uri or ":" not in uri.split("@")[-1]:
81+
if protocol != 'vmess':
82+
print(f"! Invalid URI structure (missing @ or :). Skipping: {uri[:40]}...")
83+
return None
84+
8485
parser_map = {
8586
"vless": _parse_vless, "vmess": _parse_vmess, "trojan": _parse_trojan,
86-
"ss": _parse_shadowsocks, "socks": _parse_socks, "wireguard": _parse_wireguard,"hysteria": _parse_hysteria, "hysteria2": _parse_hysteria,"hy2": _parse_hysteria,
87+
"ss": _parse_shadowsocks, "socks": _parse_socks, "wireguard": _parse_wireguard,
88+
"hysteria": _parse_hysteria, "hysteria2": _parse_hysteria, "hy2": _parse_hysteria,
8789
}
8890
parser = parser_map.get(protocol)
8991

9092
if not parser:
9193
print(f"note: Unsupported protocol found: {protocol}")
9294
return None
9395

94-
# This part extracts the core address/port for protocols that have it in a standard format
9596
common = {"protocol": protocol, "tag": tag, "address": "", "port": 0}
96-
match = re.search(r"@([^:]+):(\d+)", uri)
97+
98+
match = re.search(r"@([^:]+):(\d+)", uri.split("?")[0])
9799
if match:
98100
common["address"] = match.group(1)
99101
common["port"] = int(match.group(2))
102+
elif protocol != 'vmess':
103+
print(f"! Could not extract host/port from URI. Skipping: {uri[:40]}...")
104+
return None
100105

101106
return parser(uri, common)
102107

103108
except Exception as e:
104109
print(f"! CRITICAL ERROR while parsing URI '{config_uri[:30]}...': {e}")
105110
return None
106111

107-
# --- Private Parsing Helpers ---
112+
108113

109114
def _parse_vless(uri: str, common: dict) -> ConfigParams:
110115
parsed_url = urllib.parse.urlparse(uri)
@@ -188,10 +193,6 @@ def _parse_hysteria(uri: str, common: dict) -> ConfigParams:
188193
hy2_obfs_password=params.get("obfs-password"),
189194
)
190195

191-
# ! 3. Up
192-
# ! =======================================================================
193-
# ! === STEP 3: THE CONFIG BUILDER ENGINE ===
194-
# ! =======================================================================
195196

196197
class XrayConfigBuilder:
197198
def __init__(self):
@@ -227,18 +228,33 @@ def add_outbound(self, outbound_config: Dict[str, Any]):
227228
def build_outbound_from_params(self, params: ConfigParams, fragment_config: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
228229
"""
229230
* The main engine. Converts ConfigParams into a complete Xray outbound dictionary.
230-
* Now with added support for TLS fragmentation.
231+
* Now correctly maps short protocol names to Xray's official protocol names.
231232
"""
232-
if params.protocol in ["hysteria", "hysteria2","hy2"]:
233-
params.protocol = "socks"
233+
# ! =========================================================
234+
# ! === THE FINAL FIX: MAP SHORT NAMES TO XRAY'S REAL NAMES ===
235+
# ! =========================================================
236+
protocol_map = {
237+
"vless": "vless",
238+
"vmess": "vmess",
239+
"trojan": "trojan",
240+
"ss": "shadowsocks", # This was the main bug
241+
"socks": "socks",
242+
"wireguard": "wireguard",
243+
}
244+
245+
xray_protocol_name = protocol_map.get(params.protocol)
246+
if not xray_protocol_name:
247+
# This protocol is not meant for Xray (like Hysteria)
248+
return None
249+
234250
use_fragment = fragment_config is not None
235251
stream_settings = self._build_stream_settings(params, fragment=use_fragment, **kwargs)
236252

237253
protocol_settings = self._build_protocol_settings(params)
238254

239255
outbound = {
240256
"tag": params.tag,
241-
"protocol": params.protocol,
257+
"protocol": xray_protocol_name, # ! Use the correct, full protocol name
242258
"settings": protocol_settings,
243259
"streamSettings": stream_settings
244260
}

python_v2ray/core.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,6 @@ def get_stats(self, tag: str, reset: bool = False) -> Optional[Dict[str, int]]:
115115
return None
116116

117117
if self._api_client is None:
118-
# Lazy initialization of the API client
119118
api_address = f"127.0.0.1:{self.api_port}"
120119
self._api_client = XrayApiClient(api_address)
121120

python_v2ray/tester.py

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,109 @@
1-
import subprocess, json, os, sys
1+
import subprocess, json, os, sys, time, logging
22
from pathlib import Path
33
from typing import List, Dict, Any, Optional
4-
from .config_parser import XrayConfigBuilder, ConfigParams
4+
5+
# ! Import the CORRECT classes from your parser
6+
from .config_parser import ConfigParams, XrayConfigBuilder
7+
8+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - [%(levelname)s] - %(message)s')
59

610
class ConnectionTester:
711
def __init__(self, vendor_path: str, core_engine_path: str):
812
self.vendor_path = Path(vendor_path)
913
self.core_engine_path = Path(core_engine_path)
10-
1114
if sys.platform == "win32":
12-
self.tester_exe = "core_engine.exe"; self.xray_exe = "xray.exe"; self.hysteria_exe = "hysteria.exe"
15+
self.tester_exe, self.xray_exe, self.hysteria_exe = "core_engine.exe", "xray.exe", "hysteria.exe"
1316
elif sys.platform == "darwin":
14-
self.tester_exe = "core_engine_macos"; self.xray_exe = "xray_macos"; self.hysteria_exe = "hysteria_macos"
17+
self.tester_exe, self.xray_exe, self.hysteria_exe = "core_engine_macos", "xray_macos", "hysteria_macos"
1518
else:
16-
self.tester_exe = "core_engine_linux"; self.xray_exe = "xray_linux"; self.hysteria_exe = "hysteria_linux"
19+
self.tester_exe, self.xray_exe, self.hysteria_exe = "core_engine_linux", "xray_linux", "hysteria_linux"
20+
if not (self.core_engine_path / self.tester_exe).is_file(): raise FileNotFoundError("Tester executable not found")
1721

18-
if not (self.core_engine_path / self.tester_exe).is_file(): raise FileNotFoundError(f"Tester executable not found")
19-
20-
def test_outbounds(self, parsed_params: List[ConfigParams], fragment_config: Optional[Dict[str, Any]] = None, timeout: int = 60) -> List[Dict[str, Any]]:
22+
def test_uris(self, parsed_params: List[ConfigParams], fragment_config: Optional[Dict[str, Any]] = None, timeout: int = 90) -> List[Dict[str, Any]]:
23+
"""
24+
* Takes a list of PRE-PARSED ConfigParams objects and tests them using the correct client.
25+
"""
2126
if not parsed_params: return []
2227

23-
test_configs = []
24-
base_port = 20800
25-
builder = XrayConfigBuilder()
26-
27-
for i, params in enumerate(parsed_params):
28-
config_dict = {}
29-
client_path = ""
30-
protocol = params.protocol
31-
32-
if protocol in ["hysteria", "hysteria2"]:
33-
protocol = "hysteria2"
34-
client_path = str(self.vendor_path / self.hysteria_exe)
35-
config_dict = {
36-
"server": f"{params.address}:{params.port}",
37-
"auth": params.hy2_password,
38-
"socks5": {"listen": f"127.0.0.1:{base_port + i}"},
39-
"tls": {"sni": params.sni, "insecure": True}
40-
}
28+
hysteria_params = []
29+
xray_params = []
30+
31+
# ! FIX: Iterate over the list of OBJECTS, don't re-parse them.
32+
for params in parsed_params:
33+
if params.protocol in ["hysteria", "hysteria2", "hy2"]:
34+
hysteria_params.append(params)
4135
else:
42-
client_path = str(self.vendor_path / self.xray_exe)
36+
xray_params.append(params)
37+
38+
all_results = []
39+
40+
if hysteria_params:
41+
logging.info(f"Testing {len(hysteria_params)} Hysteria configuration(s) individually...")
42+
hysteria_results = self._test_individual_clients(hysteria_params, self.hysteria_exe, "hysteria2", timeout)
43+
all_results.extend(hysteria_results)
44+
45+
if xray_params:
46+
logging.info(f"Testing {len(xray_params)} Xray configuration(s) with one merged instance...")
47+
48+
base_port = 20800
49+
builder = XrayConfigBuilder()
50+
tests_to_run = []
51+
52+
for i, params in enumerate(xray_params):
53+
inbound_port = base_port + i
54+
inbound_tag = f"inbound_{i}"
55+
4356
outbound = builder.build_outbound_from_params(params, fragment_config=fragment_config)
44-
if fragment_config:
45-
if "streamSettings" not in outbound: outbound["streamSettings"] = {}
46-
outbound["streamSettings"]["sockopt"] = {"dialerProxy": "fragment"}
47-
config_dict = outbound
48-
49-
test_configs.append({
50-
"tag": params.tag,
51-
"protocol": protocol,
52-
"config": config_dict,
53-
"test_port": base_port + i,
54-
"client_path": client_path,
55-
"fragment_config": fragment_config,
57+
builder.add_outbound(outbound)
58+
59+
builder.add_inbound({"tag": inbound_tag, "port": inbound_port, "listen": "127.0.0.1", "protocol": "socks", "settings": {"auth": "noauth", "udp": True, "userLevel": 0}})
60+
builder.config["routing"]["rules"].append({"type": "field", "inboundTag": [inbound_tag], "outboundTag": outbound["tag"]})
61+
tests_to_run.append({"tag": outbound["tag"], "test_port": inbound_port, "listen_ip": "127.0.0.1"})
62+
63+
builder.add_outbound({"protocol": "freedom", "tag": "direct"})
64+
builder.add_outbound({"protocol": "blackhole", "tag": "block"})
65+
66+
if fragment_config:
67+
builder.add_fragment_outbound(fragment_config)
68+
69+
temp_config_path = self.core_engine_path / "merged_xray_config.json"
70+
with open(temp_config_path, "w", encoding='utf-8') as f: f.write(builder.to_json())
71+
72+
xray_process = None
73+
try:
74+
xray_process = subprocess.Popen([str(self.vendor_path / self.xray_exe), "-c", str(temp_config_path)])
75+
logging.info(f"Merged Xray instance started (PID: {xray_process.pid}). Waiting for initialization...")
76+
time.sleep(1.5)
77+
logging.info(f"Sending {len(tests_to_run)} Xray test jobs to Go engine...")
78+
xray_results = self._run_go_tester(tests_to_run, timeout)
79+
all_results.extend(xray_results)
80+
finally:
81+
if xray_process: xray_process.terminate(); xray_process.wait()
82+
if temp_config_path.exists(): temp_config_path.unlink()
83+
84+
return all_results
85+
86+
def _test_individual_clients(self, params_list: List[ConfigParams], client_exe: str, protocol_name: str, timeout: int) -> List[Dict[str, Any]]:
87+
test_jobs = []
88+
base_port = 30800
89+
ip_counter = 2
90+
for i, params in enumerate(params_list):
91+
test_jobs.append({
92+
"tag": params.tag, "protocol": protocol_name,
93+
"config_uri": f"{params.protocol}://{params.hy2_password}@{params.address}:{params.port}?sni={params.sni}",
94+
"listen_ip": f"127.0.0.{ip_counter}", "test_port": base_port + i,
95+
"client_path": str(self.vendor_path / client_exe)
5696
})
97+
ip_counter += 1
5798

58-
input_json = json.dumps(test_configs, default=lambda o: o.__dict__)
99+
return self._run_go_tester(test_jobs, timeout)
59100

101+
def _run_go_tester(self, payload: List[Dict[str, Any]], timeout: int) -> List[Dict[str, Any]]:
102+
input_json = json.dumps(payload, default=lambda o: o.__dict__)
60103
try:
61104
with subprocess.Popen([str(self.core_engine_path / self.tester_exe)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8') as process:
62105
stdout, stderr = process.communicate(input=input_json, timeout=timeout)
63-
if process.returncode != 0: print(f"! Go engine error:\n{stderr}"); return []
106+
if process.returncode != 0: logging.error(f"Go engine error:\n{stderr}"); return []
64107
return json.loads(stdout) if stdout else []
65108
except Exception as e:
66-
print(f"! Tester execution error: {e}"); return []
109+
logging.error(f"Tester execution error: {e}"); return []

vendor/gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)