Skip to content

Commit a512b22

Browse files
Add files via upload
1 parent a092be3 commit a512b22

3 files changed

Lines changed: 103 additions & 136 deletions

File tree

python_v2ray/config_parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,8 @@ def _parse_wireguard(uri: str, common: dict) -> ConfigParams:
178178
def _parse_hysteria(uri: str, common: dict) -> ConfigParams:
179179
params = _parse_query_params(urllib.parse.urlparse(uri).query)
180180
password = urllib.parse.urlparse(uri).username
181-
182181
return ConfigParams(
183182
**common,
184-
protocol="hysteria2",
185183
hy2_password=password,
186184
security="tls",
187185
sni=params.get("sni", common['address']),
@@ -231,6 +229,8 @@ def build_outbound_from_params(self, params: ConfigParams, fragment_config: Opti
231229
* The main engine. Converts ConfigParams into a complete Xray outbound dictionary.
232230
* Now with added support for TLS fragmentation.
233231
"""
232+
if params.protocol in ["hysteria", "hysteria2","hy2"]:
233+
params.protocol = "socks"
234234
use_fragment = fragment_config is not None
235235
stream_settings = self._build_stream_settings(params, fragment=use_fragment, **kwargs)
236236

python_v2ray/downloader.py

Lines changed: 55 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
1-
import requests
2-
import sys
3-
import os
4-
import zipfile
5-
import io
6-
import platform
1+
# python_v2ray/downloader.py
2+
3+
import requests, sys, os, zipfile, io, platform
74
from pathlib import Path
85
from typing import Optional
96

107
XRAY_REPO = "GFW-knocker/Xray-core"
118
OWN_REPO = "arshiacomplus/python_v2ray"
9+
HYSTERIA_REPO = "apernet/hysteria"
1210

1311
class BinaryDownloader:
14-
"""
15-
* Handles the logic of downloading and extracting necessary binaries
16-
* from GitHub Releases for the current operating system and architecture.
17-
"""
1812
def __init__(self, project_root: Path):
1913
self.project_root = project_root
2014
self.vendor_path = self.project_root / "vendor"
@@ -23,52 +17,54 @@ def __init__(self, project_root: Path):
2317
self.arch = self._get_arch_name()
2418

2519
def _get_os_name(self) -> str:
26-
"""* Determines the OS name used in release assets."""
2720
if sys.platform == "win32": return "windows"
28-
if sys.platform == "darwin": return "macos"
21+
if sys.platform == "darwin": return "darwin"
2922
return "linux"
3023

3124
def _get_arch_name(self) -> str:
32-
"""* Determines the architecture name (e.g., 64, 32, arm64-v8a)."""
3325
machine = platform.machine().lower()
34-
if "amd64" in machine or "x86_64" in machine:
35-
return "64"
36-
if "arm64" in machine or "aarch64" in machine:
37-
return "arm64-v8a"
38-
if "386" in machine or "x86" in machine:
39-
return "32"
26+
if "amd64" in machine or "x86_64" in machine: return "amd64"
27+
if "arm64" in machine or "aarch64" in machine: return "arm64"
28+
if "386" in machine or "x86" in machine: return "386"
4029
return "unsupported"
4130

4231
def _get_asset_url(self, assets: list, name_prefix: str) -> Optional[str]:
43-
"""
44-
* It finds the download URL for a specific asset based on OS and arch.
45-
"""
46-
asset_name = f"{name_prefix}-{self.os_name}-{self.arch}.zip"
47-
print(f"note: Searching for asset: {asset_name}")
32+
# * This logic is now smarter to handle different naming conventions
33+
34+
if name_prefix == "hysteria":
35+
asset_name = f"{name_prefix}-{self.os_name}-{self.arch}"
36+
if self.os_name == "windows":
37+
asset_name += ".exe"
38+
elif name_prefix == "Xray":
39+
arch_name = "64" if self.arch == "amd64" else self.arch
40+
os_name = "macos" if self.os_name == "darwin" else self.os_name # Xray uses macos
41+
asset_name = f"{name_prefix}-{os_name}-{arch_name}.zip"
42+
else:
43+
arch_name = "64" if self.arch == "amd64" else self.arch
44+
os_name = "macos" if self.os_name == "darwin" else self.os_name
45+
asset_name = f"{name_prefix}-{os_name}-{arch_name}.zip"
4846

47+
print(f"note: Searching for asset: {asset_name}")
4948
for asset in assets:
5049
if asset['name'].lower() == asset_name.lower():
5150
return asset['browser_download_url']
5251
return None
5352

5453
def ensure_binary(self, name: str, target_dir: Path, repo: str) -> bool:
55-
"""
56-
* Checks for a binary, and if not found, downloads and extracts the
57-
* entire contents of the zip file (including .dat files).
58-
"""
5954
exe_name = f"{name}.exe" if sys.platform == "win32" else name
6055
target_file = target_dir / exe_name
6156

62-
geoip_file = target_dir / "geoip.dat"
63-
64-
if target_file.is_file() and (name != "xray" or geoip_file.is_file()):
65-
print(f"* Binary '{exe_name}' and necessary assets already exist.")
57+
if target_file.is_file():
58+
print(f"* Binary '{exe_name}' already exists.")
6659
return True
6760

68-
print(f"! Binary '{exe_name}' not found. Attempting to download from '{repo}'...")
69-
61+
print(f"! Binary '{exe_name}' not found. Downloading from '{repo}'...")
7062
try:
71-
release_url = f"https://api.github.com/repos/{repo}/releases/latest"
63+
if name == "hysteria":
64+
release_url = f"https://api.github.com/repos/{repo}/releases/tags/app%2Fv2.6.2" # ! Hardcoded to a specific stable version
65+
else:
66+
release_url = f"https://api.github.com/repos/{repo}/releases/latest"
67+
7268
response = requests.get(release_url, timeout=10)
7369
response.raise_for_status()
7470
assets = response.json().get("assets", [])
@@ -77,36 +73,46 @@ def ensure_binary(self, name: str, target_dir: Path, repo: str) -> bool:
7773
download_url = self._get_asset_url(assets, asset_prefix)
7874

7975
if not download_url:
80-
print(f"! ERROR: Could not find downloadable asset for '{name}' matching '{self.os_name}-{self.arch}' in repo '{repo}'.")
76+
print(f"! ERROR: Could not find downloadable asset for '{name}'.")
8177
return False
8278

83-
print(f"* Downloading from: {download_url}")
79+
print(f"* Downloading: {download_url}")
8480
asset_response = requests.get(download_url, timeout=120, stream=True)
8581
asset_response.raise_for_status()
8682

87-
print(f"* Extracting all files to '{target_dir}'...")
88-
with zipfile.ZipFile(io.BytesIO(asset_response.content)) as z:
89-
z.extractall(path=target_dir)
90-
print(f"* Successfully downloaded and extracted all assets for '{name}'.")
91-
92-
if sys.platform != "win32" and target_file.is_file():
83+
if not download_url.endswith('.zip'):
84+
with open(target_file, "wb") as f:
85+
f.write(asset_response.content)
86+
else:
87+
with zipfile.ZipFile(io.BytesIO(asset_response.content)) as z:
88+
for member_name in z.namelist():
89+
if Path(member_name).name.lower() == exe_name.lower():
90+
source = z.open(member_name)
91+
target = open(target_file, "wb")
92+
with source, target: target.write(source.read())
93+
94+
for dat_file in ["geoip.dat", "geosite.dat"]:
95+
if dat_file in z.namelist():
96+
with z.open(dat_file) as source_dat, open(target_dir / dat_file, "wb") as target_dat:
97+
target_dat.write(source_dat.read())
98+
99+
print(f"* Successfully downloaded '{exe_name}'.")
100+
if sys.platform != "win32":
93101
os.chmod(target_file, 0o755)
94-
95102
return True
96103

97104
except Exception as e:
98-
print(f"! ERROR during download/extraction: {e}")
105+
print(f"! ERROR during download/extraction for '{name}': {e}")
99106
return False
100107

101108
def ensure_all(self):
102-
"""* Ensures all necessary binaries are present."""
103109
print("--- Checking for necessary binaries & databases ---")
104110
self.vendor_path.mkdir(exist_ok=True)
105111
self.core_engine_path.mkdir(exist_ok=True)
106-
107112
xray_ok = self.ensure_binary("xray", self.vendor_path, XRAY_REPO)
108113
engine_ok = self.ensure_binary("core_engine", self.core_engine_path, OWN_REPO)
114+
hysteria_ok = self.ensure_binary("hysteria", self.vendor_path, HYSTERIA_REPO)
109115

110116
print("-------------------------------------------------")
111-
if not (xray_ok and engine_ok):
112-
raise RuntimeError("Could not obtain all necessary files.")
117+
if not (xray_ok and engine_ok and hysteria_ok):
118+
raise RuntimeError("Could not obtain all necessary binaries.")

python_v2ray/tester.py

Lines changed: 46 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,66 @@
1-
2-
import subprocess
3-
import json
4-
import os
5-
from typing import List, Dict, Any, Optional
1+
import subprocess, json, os, sys
62
from pathlib import Path
7-
import sys
3+
from typing import List, Dict, Any, Optional
4+
from .config_parser import XrayConfigBuilder, ConfigParams
85

96
class ConnectionTester:
10-
"""
11-
* This class acts as a bridge between Python and the high-performance Go tester engine.
12-
"""
13-
147
def __init__(self, vendor_path: str, core_engine_path: str):
15-
"""
16-
Args:
17-
vendor_path (str): Path to the 'vendor' directory (for xray executable).
18-
core_engine_path (str): Path to the 'core_engine' directory (for go engine).
19-
"""
8+
self.vendor_path = Path(vendor_path)
9+
self.core_engine_path = Path(core_engine_path)
10+
2011
if sys.platform == "win32":
21-
tester_exe_name = "core_engine.exe"
22-
xray_exe_name = "xray.exe"
12+
self.tester_exe = "core_engine.exe"; self.xray_exe = "xray.exe"; self.hysteria_exe = "hysteria.exe"
2313
elif sys.platform == "darwin":
24-
tester_exe_name = "core_engine_macos"
25-
xray_exe_name = "xray_macos"
26-
else: # Linux and others
27-
tester_exe_name = "core_engine_linux"
28-
xray_exe_name = "xray_linux"
29-
30-
# * Use pathlib for safe and cross-platform path construction
31-
self.tester_path = str(Path(core_engine_path) / tester_exe_name)
32-
self.xray_path = str(Path(vendor_path) / xray_exe_name)
33-
34-
if not os.path.exists(self.tester_path):
35-
raise FileNotFoundError(f"Tester executable not found at: {self.tester_path}")
36-
if not os.path.exists(self.xray_path):
37-
raise FileNotFoundError(f"Xray executable not found at: {self.xray_path}")
38-
def test_outbounds(self, outbounds: List[Dict[str, Any]], fragment_config: Optional[Dict[str, Any]] = None, timeout: int = 60) -> List[Dict[str, Any]]:
39-
"""
40-
* Tests a list of outbound configs concurrently using the Go engine, with optional fragmentation.
14+
self.tester_exe = "core_engine_macos"; self.xray_exe = "xray_macos"; self.hysteria_exe = "hysteria_macos"
15+
else:
16+
self.tester_exe = "core_engine_linux"; self.xray_exe = "xray_linux"; self.hysteria_exe = "hysteria_linux"
4117

42-
Args:
43-
outbounds (List[Dict[str, Any]]): A list of Xray outbound config dictionaries.
44-
fragment_config (Optional[Dict[str, Any]]): Settings for TLS fragmentation.
45-
timeout (int): Total time in seconds to wait for all tests to complete.
18+
if not (self.core_engine_path / self.tester_exe).is_file(): raise FileNotFoundError(f"Tester executable not found")
4619

47-
Returns:
48-
A list of result dictionaries.
49-
"""
50-
if not outbounds:
51-
return []
52-
fragment_json_bytes = json.dumps(fragment_config).encode('utf-8') if fragment_config else b'null'
20+
def test_outbounds(self, parsed_params: List[ConfigParams], fragment_config: Optional[Dict[str, Any]] = None, timeout: int = 60) -> List[Dict[str, Any]]:
21+
if not parsed_params: return []
5322

5423
test_configs = []
5524
base_port = 20800
56-
for i, outbound in enumerate(outbounds):
57-
if fragment_config and outbound.get("protocol") not in ["freedom", "blackhole"]:
58-
if "streamSettings" not in outbound:
59-
outbound["streamSettings"] = {}
60-
outbound["streamSettings"]["sockopt"] = {"dialerProxy": "fragment"}
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+
}
41+
else:
42+
client_path = str(self.vendor_path / self.xray_exe)
43+
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
6148

6249
test_configs.append({
63-
"tag": outbound.get("tag", f"outbound_{i}"),
64-
"config": outbound,
50+
"tag": params.tag,
51+
"protocol": protocol,
52+
"config": config_dict,
6553
"test_port": base_port + i,
66-
"xray_path": self.xray_path,
67-
"fragment_config": json.loads(fragment_json_bytes),
54+
"client_path": client_path,
55+
"fragment_config": fragment_config,
6856
})
6957

70-
input_json = json.dumps(test_configs)
58+
input_json = json.dumps(test_configs, default=lambda o: o.__dict__)
7159

7260
try:
73-
with subprocess.Popen(
74-
[self.tester_path],
75-
stdin=subprocess.PIPE,
76-
stdout=subprocess.PIPE,
77-
stderr=subprocess.PIPE,
78-
text=True,
79-
encoding='utf-8'
80-
) as process:
61+
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:
8162
stdout, stderr = process.communicate(input=input_json, timeout=timeout)
82-
83-
if process.returncode != 0:
84-
print(f"! Go engine exited with an error (return code {process.returncode}):")
85-
print(f"--- STDERR ---:\n{stderr}\n--------------")
86-
return []
87-
88-
if not stdout:
89-
print("! Go engine returned no output.")
90-
return []
91-
92-
results = json.loads(stdout)
93-
return results
94-
95-
except subprocess.TimeoutExpired:
96-
process.kill()
97-
print(f"! Testing process timed out after {timeout} seconds.")
98-
return []
99-
except json.JSONDecodeError:
100-
print("! Failed to decode JSON from Go engine. Raw output:")
101-
print(f"--- STDOUT ---:\n{stdout}\n--------------")
102-
return []
63+
if process.returncode != 0: print(f"! Go engine error:\n{stderr}"); return []
64+
return json.loads(stdout) if stdout else []
10365
except Exception as e:
104-
print(f"! An unexpected error occurred while running the tester: {e}")
105-
return []
66+
print(f"! Tester execution error: {e}"); return []

0 commit comments

Comments
 (0)