Skip to content

Commit 2a647c3

Browse files
Add files via upload
1 parent 1758898 commit 2a647c3

6 files changed

Lines changed: 140 additions & 48 deletions

File tree

examples/03_stats_viewer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def main():
1414
* Demonstrates how to run Xray with the API enabled and fetch live stats.
1515
"""
1616
# ! Replace with your own REAL and WORKING VLESS URI
17-
vless_uri = "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#%40i10VPN+%F0%9F%9A%80%7C+%D8%A8%D8%A7+%D9%85%D8%A7+%D9%85%D8%AA%D8%B5%D9%84+%D8%A8%D9%85%D9%88%D9%86%E2%9A%A1"
17+
vless_uri = "vless://"
1818

1919
if "YOUR_UUID" in vless_uri:
2020
print("! Please replace the placeholder URI in the script with your own to run this demo.")

examples/04_connection_tester.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,14 @@ def main():
2929

3030

3131
test_uris = [
32-
"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",
33-
"hy2://dongtaiwang.com@208.87.243.187:22222/?insecure=1&sni=www.bing.com#hy2",
34-
"hy2://dongtaiwang.com@51.159.111.32:5355/?insecure=1&sni=www.bing.com#hy2",
35-
"hy2://7GEEGxAfgQaVPQX0PGk7lIuj3I@158.41.110.234:10820/?insecure=1&sni=bing.com#hy2",
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",
32+
"vless:///"
3933
# ... Add your other real URIs here ...
4034
]
4135

42-
fragment_settings = { "packets": "tlshello", "length": "10-30", "interval": "1-5"}
43-
36+
fragment_settings = { "packets": "tlshello", "length": "10-20", "interval": "10-20"}
37+
# mux_settings = {"enabled": True, "concurrency": 8} # ! It's better to be flase
38+
fragment_settings= None
39+
mux_settings = None
4440
print("* Parsing all URIs...")
4541
parsed_configs = [p for p in (parse_uri(uri) for uri in test_uris) if p]
4642

@@ -52,8 +48,11 @@ def main():
5248

5349
tester = ConnectionTester(vendor_path=str(vendor_dir), core_engine_path=str(core_engine_dir))
5450

55-
results = tester.test_uris(parsed_configs, fragment_config=fragment_settings)
56-
51+
results = tester.test_uris(
52+
parsed_params=parsed_configs,
53+
fragment_config=fragment_settings,
54+
mux_config=mux_settings
55+
)
5756
print("\n" + "="*20 + " TEST RESULTS " + "="*20)
5857
if results:
5958
sorted_results = sorted(results, key=lambda x: x.get('ping_ms', 9999))

pyproject.toml

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,39 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "python_v2ray"
7-
version = "0.0.1"
7+
version = "0.1.0"
88
authors = [
9-
{ name="Your Name", email="you@example.com" },
9+
{ name="Arshia", email="arshiacomplus@gmail.com" },
1010
]
11-
description = "A powerful, high-level Python wrapper for V2Ray/Xray-core."
11+
description = "A powerful, high-level Python wrapper for managing and testing V2Ray/Xray-core and Hysteria clients."
1212
readme = "README.md"
13+
license = { file = "LICENSE" }
1314
requires-python = ">=3.8"
15+
keywords = ["v2ray", "xray", "hysteria", "proxy", "tester", "api", "wrapper"]
16+
classifiers = [
17+
"Programming Language :: Python :: 3",
18+
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
19+
"Operating System :: OS Independent",
20+
"Intended Audience :: Developers",
21+
"Topic :: Internet :: Proxy Servers",
22+
"Topic :: System :: Networking",
23+
]
24+
1425
dependencies = [
15-
"grpcio",
16-
"grpcio-tools",
1726
"requests",
27+
"grpcio",
28+
"protobuf",
1829
]
1930

2031
[project.urls]
2132
"Homepage" = "https://github.com/arshiacomplus/python_v2ray"
2233
"Bug Tracker" = "https://github.com/arshiacomplus/python_v2ray/issues"
2334

24-
[tool.setuptools.packages.find]
25-
where = ["."]
26-
include = ["python_v2ray*"]
27-
exclude = ["examples*", "tests*", "core_engine*", "vendor*"]
35+
[tool.setuptools]
36+
packages = ["python_v2ray", "python_v2ray.protos.app.stats.command"]
37+
38+
[tool.setuptools.package-data]
39+
python_v2ray = [
40+
"vendor/*",
41+
"core_engine/*",
42+
]

python_v2ray/config_parser.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ class ConfigParams:
6060
hy2_obfs: Optional[str] = ""
6161
hy2_obfs_password: Optional[str] = ""
6262

63+
# * Mvless Extra Fields
64+
mux_enabled: bool = False
65+
mux_concurrency: int = 8
66+
fragment_enabled: bool = False
67+
fragment_packets: Optional[str] = ""
68+
fragment_length: Optional[str] = ""
69+
fragment_interval: Optional[str] = ""
70+
6371

6472
def _parse_query_params(query: str) -> Dict[str, str]:
6573
"""* A utility to parse URL query parameters into a dictionary."""
@@ -83,7 +91,7 @@ def parse_uri(config_uri: str) -> Optional[ConfigParams]:
8391
return None
8492

8593
parser_map = {
86-
"vless": _parse_vless, "vmess": _parse_vmess, "trojan": _parse_trojan,
94+
"vless": _parse_vless, "mvless": _parse_vless, "vmess": _parse_vmess, "trojan": _parse_trojan,
8795
"ss": _parse_shadowsocks, "socks": _parse_socks, "wireguard": _parse_wireguard,
8896
"hysteria": _parse_hysteria, "hysteria2": _parse_hysteria, "hy2": _parse_hysteria,
8997
}
@@ -102,8 +110,10 @@ def parse_uri(config_uri: str) -> Optional[ConfigParams]:
102110
elif protocol != 'vmess':
103111
print(f"! Could not extract host/port from URI. Skipping: {uri[:40]}...")
104112
return None
105-
106-
return parser(uri, common)
113+
params = parser(uri, common)
114+
if protocol == "mvless" and params:
115+
_parse_mvless_extensions(params, uri)
116+
return params
107117

108118
except Exception as e:
109119
print(f"! CRITICAL ERROR while parsing URI '{config_uri[:30]}...': {e}")
@@ -122,6 +132,25 @@ def _parse_vless(uri: str, common: dict) -> ConfigParams:
122132
fp=params.get("fp", ""), alpn=params.get("alpn", ""), flow=params.get("flow", ""),
123133
encryption=params.get("encryption", "none"),
124134
)
135+
def _parse_mvless_extensions(params: ConfigParams, uri: str):
136+
"""Parses Mux and Fragment parameters specific to the Mvless protocol and modifies the ConfigParams object."""
137+
try:
138+
query_params = urllib.parse.parse_qs(urllib.parse.urlparse(uri).query)
139+
if 'mux' in query_params and query_params['mux'][0].upper() == 'ON':
140+
params.mux_enabled = True
141+
if 'muxConcurrency' in query_params:
142+
try:
143+
params.mux_concurrency = int(query_params['muxConcurrency'][0])
144+
except (ValueError, IndexError):
145+
pass
146+
147+
if 'packets' in query_params and 'length' in query_params and 'interval' in query_params:
148+
params.fragment_enabled = True
149+
params.fragment_packets = query_params['packets'][0]
150+
params.fragment_length = query_params['length'][0]
151+
params.fragment_interval = query_params['interval'][0]
152+
except Exception as e:
153+
print(f"! Error parsing mvless extensions: {e}")
125154

126155
def _parse_vmess(uri: str, common: dict) -> ConfigParams:
127156
encoded_part = uri.replace("vmess://", "")
@@ -230,18 +259,16 @@ def build_outbound_from_params(self, params: ConfigParams, fragment_config: Opti
230259
* The main engine. Converts ConfigParams into a complete Xray outbound dictionary.
231260
* Now correctly maps short protocol names to Xray's official protocol names.
232261
"""
233-
# ! =========================================================
234-
# ! === THE FINAL FIX: MAP SHORT NAMES TO XRAY'S REAL NAMES ===
235-
# ! =========================================================
236262
protocol_map = {
237263
"vless": "vless",
264+
"mvless" : "mvless",
238265
"vmess": "vmess",
239266
"trojan": "trojan",
240-
"ss": "shadowsocks", # This was the main bug
267+
"ss": "shadowsocks",
241268
"socks": "socks",
242269
"wireguard": "wireguard",
243270
}
244-
271+
245272
xray_protocol_name = protocol_map.get(params.protocol)
246273
if not xray_protocol_name:
247274
# This protocol is not meant for Xray (like Hysteria)
@@ -251,7 +278,11 @@ def build_outbound_from_params(self, params: ConfigParams, fragment_config: Opti
251278
stream_settings = self._build_stream_settings(params, fragment=use_fragment, **kwargs)
252279

253280
protocol_settings = self._build_protocol_settings(params)
254-
281+
if params.protocol == "mvless" and params.mux_enabled:
282+
try:
283+
outbound["mux"] = {"enabled": True if outbound["mux"].upper() == "ON" else False , "concurrency": params.mux_concurrency}
284+
except Exception:
285+
print("! No mux found in mvless")
255286
outbound = {
256287
"tag": params.tag,
257288
"protocol": xray_protocol_name, # ! Use the correct, full protocol name
@@ -277,17 +308,28 @@ def _build_stream_settings(self, params: ConfigParams, **kwargs) -> Dict[str, An
277308
stream_settings["tlsSettings"] = security_settings
278309

279310
header_config = {"type": params.header_type if params.header_type else "none"}
280-
if params.header_type == "http":
281-
header_config["request"] = {"method": "GET", "path": ["/"], "headers": {"Host": [params.host], "User-Agent": ["Mozilla/5.0"], "Connection": ["keep-alive"]}}
311+
host_for_header = params.host if params.host else params.sni
282312

283313
network_map = {
284314
"tcp": {"tcpSettings": {"header": header_config}}, "kcp": {"kcpSettings": {"header": header_config, "seed": params.path}},
285-
"ws": {"wsSettings": {"path": params.path, "headers": {"Host": params.host}}}, "h2": {"httpSettings": {"host": [params.host], "path": params.path}},
286-
"quic": {"quicSettings": {"security": params.host, "key": params.path, "header": header_config}}, "grpc": {"grpcSettings": {"serviceName": params.path, "multiMode": (params.mode == "multi")}},
315+
"ws": {"wsSettings": {"path": params.path, "headers": {"Host": host_for_header}}},
316+
"h2": {"httpSettings": {"host": [host_for_header], "path": params.path}},
317+
"quic": {"quicSettings": {"security": params.host, "key": params.path, "header": header_config}},
318+
"grpc": {"grpcSettings": {"serviceName": params.path, "multiMode": (params.mode == "multi")}},
287319
}
288320
stream_settings.update(network_map.get(params.network, {}))
321+
if params.protocol == "mvless" and params.fragment_enabled:
322+
stream_settings["fragment"] = {
323+
"packets": params.fragment_packets,
324+
"length": params.fragment_length,
325+
"interval": params.fragment_interval
326+
}
327+
328+
329+
330+
if kwargs.get("fragment_config") and not params.fragment_enabled:
331+
stream_settings["sockopt"] = {"dialerProxy": "fragment"}
289332

290-
if kwargs.get("fragment"): stream_settings["sockopt"] = {"dialerProxy": "fragment", "mark": 255}
291333
return stream_settings
292334
def add_fragment_outbound(self, fragment_config: Dict[str, Any]):
293335
"""
@@ -314,7 +356,11 @@ def add_fragment_outbound(self, fragment_config: Dict[str, Any]):
314356
def _build_protocol_settings(self, params: ConfigParams) -> Dict[str, Any]:
315357
level = 0
316358
protocol = params.protocol
317-
if protocol == "vless": return {"vnext": [{"address": params.address, "port": params.port, "users": [{"id": params.id, "flow": params.flow, "encryption": "none", "level": level}]}]}
359+
if protocol in ["vless", "mvless"]:
360+
settings = {"vnext": [{"address": params.address, "port": params.port, "users": [{"id": params.id, "flow": params.flow, "encryption": "none", "level": level}]}]}
361+
if protocol == "mvless" and params.fragment_enabled:
362+
pass
363+
return settings
318364
elif protocol == "vmess": return {"vnext": [{"address": params.address, "port": params.port, "users": [{"id": params.id, "alterId": params.alter_id, "security": params.scy, "level": level}]}]}
319365
elif protocol == "trojan": return {"servers": [{"address": params.address, "port": params.port, "password": params.password, "level": level}]}
320366
elif protocol == "ss":

python_v2ray/models.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,10 @@ def to_dict(self) -> Dict[str, Any]:
3131
"settings": self.settings,
3232
}
3333
if self.stream_settings:
34-
# fixme: This needs to be expanded to handle all stream types correctly.
3534
stream_dict = {
3635
"network": self.stream_settings.network,
3736
"security": self.stream_settings.security,
3837
}
39-
# Merge extra settings like wsSettings, grpcSettings
4038
stream_dict.update(self.stream_settings.extra_settings)
4139
data["streamSettings"] = stream_dict
4240

python_v2ray/tester.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import subprocess, json, os, sys, time, logging
1+
import subprocess, json, os, sys, time, logging, socket
22
from pathlib import Path
33
from typing import List, Dict, Any, Optional
44

@@ -19,7 +19,7 @@ def __init__(self, vendor_path: str, core_engine_path: str):
1919
self.tester_exe, self.xray_exe, self.hysteria_exe = "core_engine_linux", "xray_linux", "hysteria_linux"
2020
if not (self.core_engine_path / self.tester_exe).is_file(): raise FileNotFoundError("Tester executable not found")
2121

22-
def test_uris(self, parsed_params: List[ConfigParams], fragment_config: Optional[Dict[str, Any]] = None, timeout: int = 90) -> List[Dict[str, Any]]:
22+
def test_uris(self, parsed_params: List[ConfigParams], fragment_config: Optional[Dict[str, Any]] = None, mux_config: Optional[Dict[str, Any]] = None, timeout: int = 90) -> List[Dict[str, Any]]:
2323
"""
2424
* Takes a list of PRE-PARSED ConfigParams objects and tests them using the correct client.
2525
"""
@@ -28,7 +28,6 @@ def test_uris(self, parsed_params: List[ConfigParams], fragment_config: Optional
2828
hysteria_params = []
2929
xray_params = []
3030

31-
# ! FIX: Iterate over the list of OBJECTS, don't re-parse them.
3231
for params in parsed_params:
3332
if params.protocol in ["hysteria", "hysteria2", "hy2"]:
3433
hysteria_params.append(params)
@@ -53,12 +52,34 @@ def test_uris(self, parsed_params: List[ConfigParams], fragment_config: Optional
5352
inbound_port = base_port + i
5453
inbound_tag = f"inbound_{i}"
5554

56-
outbound = builder.build_outbound_from_params(params, fragment_config=fragment_config)
57-
builder.add_outbound(outbound)
55+
# outbound = builder.build_outbound_from_params(params, fragment_config=fragment_config)
56+
# builder.add_outbound(outbound)
57+
58+
# builder.add_inbound({"tag": inbound_tag, "port": inbound_port, "listen": "127.0.0.1", "protocol": "socks", "settings": {"auth": "noauth", "udp": True, "userLevel": 0}})
59+
# builder.config["routing"]["rules"].append({"type": "field", "inboundTag": [inbound_tag], "outboundTag": outbound["tag"]})
60+
# tests_to_run.append({"tag": outbound["tag"], "test_port": inbound_port, "listen_ip": "127.0.0.1"})
61+
62+
outbound = builder.build_outbound_from_params(params)
5863

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"})
64+
if mux_config and not params.mux_enabled:
65+
if "mux" not in outbound:
66+
outbound["mux"] = {}
67+
outbound["mux"]["enabled"] = mux_config.get("enabled", True)
68+
outbound["mux"]["concurrency"] = mux_config.get("concurrency", 8)
69+
70+
outbound_tag_for_routing = outbound["tag"]
71+
if fragment_config and not params.fragment_enabled:
72+
outbound_tag_for_routing = "fragment"
73+
74+
builder.add_outbound(outbound)
75+
builder.add_inbound({
76+
"tag": inbound_tag, "port": inbound_port, "listen": "127.0.0.1",
77+
"protocol": "socks", "settings": {"auth": "noauth", "udp": True, "userLevel": 0}
78+
})
79+
builder.config["routing"]["rules"].append({
80+
"type": "field", "inboundTag": [inbound_tag], "outboundTag": outbound_tag_for_routing
81+
})
82+
tests_to_run.append({"tag": params.tag, "test_port": inbound_port, "listen_ip": "127.0.0.1"})
6283

6384
builder.add_outbound({"protocol": "freedom", "tag": "direct"})
6485
builder.add_outbound({"protocol": "blackhole", "tag": "block"})
@@ -73,13 +94,26 @@ def test_uris(self, parsed_params: List[ConfigParams], fragment_config: Optional
7394
try:
7495
xray_process = subprocess.Popen([str(self.vendor_path / self.xray_exe), "-c", str(temp_config_path)])
7596
logging.info(f"Merged Xray instance started (PID: {xray_process.pid}). Waiting for initialization...")
76-
time.sleep(1.5)
97+
last_port_to_check = base_port + len(xray_params) - 1
98+
is_ready = False
99+
for _ in range(20):
100+
try:
101+
with socket.create_connection(("127.0.0.1", last_port_to_check), timeout=0.25):
102+
is_ready = True
103+
logging.info("Xray instance is ready.")
104+
break
105+
except (socket.timeout, ConnectionRefusedError):
106+
time.sleep(0.25)
107+
108+
if not is_ready:
109+
logging.error("Xray instance failed to start up in time. Stopping test.")
110+
raise RuntimeError("Xray startup timeout")
77111
logging.info(f"Sending {len(tests_to_run)} Xray test jobs to Go engine...")
78112
xray_results = self._run_go_tester(tests_to_run, timeout)
79113
all_results.extend(xray_results)
80114
finally:
81115
if xray_process: xray_process.terminate(); xray_process.wait()
82-
if temp_config_path.exists(): temp_config_path.unlink()
116+
# if temp_config_path.exists(): temp_config_path.unlink()
83117

84118
return all_results
85119

0 commit comments

Comments
 (0)