fix(proxy-client): обновлён Xray и исправлен разбор VLESS

Зачем: профиль XHTTP с x_padding_bytes и пустым host не запускался.

Что: Xray обновлён до 26.10.10; добавлены параметры TLS, REALITY и XHTTP,
значения по умолчанию и преобразование старых названий сессии XHTTP.
Обновлены документация и регрессионные тесты.

Проверка: 59 тестов на Python 3.14 и 3.8, Bash-функции и установщик;
16 тестов разбора после уточнения проверки SHA-256. Настоящий Xray
проверил конфигурацию и выполнил HTTPS-запрос через профиль с HTTP 200.
Пользователь подтвердил работу proxy-client start.
This commit is contained in:
ddadmin committed 2026-10-12 16:21:16 -04:00
1 parent 16e85139b9
commit a30e2eb939
5 files changed
+266 -28

No files matched your search

+112 -2
View File
@@ -314,6 +314,103 @@ class VlessProfileTest(unittest.TestCase):
self.assertEqual(["h3"], result["streamSettings"]["tlsSettings"]["alpn"])
self.assertIn("downloadSettings", result["streamSettings"]["xhttpSettings"]["extra"])
def test_xhttp_padding_is_added_to_extra(self):
for value in ("100", "100-1000"):
for extra in ({}, {"downloadSettings": {"address": "cdn.example.invalid"}}):
with self.subTest(value=value, extra=extra):
url = XHTTP + "&x_padding_bytes=" + value
url += "&extra=" + quote(json.dumps(extra), safe="")
settings = outbound(url)["streamSettings"]["xhttpSettings"]
self.assertEqual(dict(extra, xPaddingBytes=value), settings["extra"])
settings = outbound(XHTTP + "&x_padding_bytes=" + value)["streamSettings"][
"xhttpSettings"
]
self.assertEqual({"xPaddingBytes": value}, settings["extra"])
def test_xhttp_extra_padding_takes_priority(self):
for value in ("100-1000", "200-500"):
with self.subTest(value=value):
extra = {"xPaddingBytes": value}
url = XHTTP + "&x_padding_bytes=100-1000&extra="
url += quote(json.dumps(extra), safe="")
self.assertEqual(extra, outbound(url)["streamSettings"]["xhttpSettings"]["extra"])
def test_xhttp_host_can_be_empty_or_absent(self):
for host in ("&host=", ""):
with self.subTest(host=host):
url = XHTTP.replace("&host=cdn.example.invalid", host)
stream = outbound(url)["streamSettings"]
self.assertEqual("", stream["xhttpSettings"]["host"])
self.assertEqual("cdn.example.invalid", stream["tlsSettings"]["serverName"])
def test_share_link_defaults(self):
url = XHTTP.replace("&encryption=none", "").replace("&sni=cdn.example.invalid", "")
url = url.replace("&fp=chrome", "").replace("&path=%2Fp", "") + "&flow="
result = outbound(url)
self.assertEqual("none", result["settings"]["encryption"])
self.assertNotIn("flow", result["settings"])
self.assertEqual("/", result["streamSettings"]["xhttpSettings"]["path"])
self.assertEqual("example.invalid", result["streamSettings"]["tlsSettings"]["serverName"])
self.assertEqual("chrome", result["streamSettings"]["tlsSettings"]["fingerprint"])
def test_reality_optional_fields_and_raw_alias(self):
for sid in ("&sid=", ""):
for flow in ("&flow=", ""):
with self.subTest(sid=sid, flow=flow):
url = REALITY.replace("&sid=SYNTHETIC_SHORT_ID", sid)
url = url.replace("&flow=xtls-rprx-vision", flow).replace("type=tcp", "type=raw")
result = outbound(url + "&pqv=VERIFY_SENTINEL&spx=%2Fspider&support-x25519mlkem768=true")
reality = result["streamSettings"]["realitySettings"]
self.assertEqual("", reality["shortId"])
self.assertEqual("VERIFY_SENTINEL", reality["mldsa65Verify"])
self.assertEqual("/spider", reality["spiderX"])
self.assertNotIn("flow", result["settings"])
self.assertNotIn("support-x25519mlkem768", json.dumps(result))
def test_tls_ech_and_optional_empty_pins(self):
for ech in ("", "ECH_SENTINEL"):
with self.subTest(ech=ech):
tls = outbound(XHTTP + "&ech=" + ech + "&pcs=&vcn=")["streamSettings"]["tlsSettings"]
self.assertEqual(ech, tls["echConfigList"])
self.assertEqual("", tls["pinnedPeerCertSha256"])
self.assertEqual("", tls["verifyPeerCertByName"])
pin = ":".join(["aa"] * 32)
for value in (pin, " " + pin + " "):
with self.subTest(pin=value):
tls = outbound(XHTTP + "&pcs=" + quote(value, safe=""))["streamSettings"]["tlsSettings"]
self.assertEqual(value, tls["pinnedPeerCertSha256"])
def test_xhttp_canonical_fields_override_extra(self):
extra = {"xPaddingBytes": "200-500", "scMinPostsIntervalMs": 10, "other": {"kept": True}}
fields = {"xPaddingBytes": "300-600", "scMinPostsIntervalMs": "20", "scMaxEachPostBytes": "1000", "uplinkChunkSize": "1024"}
url = XHTTP + "&x_padding_bytes=100-1000&extra=" + quote(json.dumps(extra), safe="")
url += "".join("&{}={}".format(key, value) for key, value in fields.items())
self.assertEqual(dict(extra, **fields), outbound(url)["streamSettings"]["xhttpSettings"]["extra"])
def test_finalmask_json_mapping(self):
finalmask = {"tcp": [], "udp": []}
for base in (XHTTP, REALITY):
with self.subTest(base=base[-20:]):
url = base + "&fm=" + quote(json.dumps(finalmask), safe="")
self.assertEqual(finalmask, outbound(url)["streamSettings"]["finalmask"])
def test_xhttp_session_names_are_updated_in_upload_and_download(self):
old = {"sessionPlacement": "header", "sessionKey": "X-Session"}
new = {"sessionIDPlacement": "header", "sessionIDKey": "X-Session"}
extra = dict(old, downloadSettings={"network": "xhttp", "xhttpSettings": {"extra": old}})
expected = dict(new, downloadSettings={"network": "xhttp", "xhttpSettings": {"extra": new}})
url = XHTTP + "&extra=" + quote(json.dumps(extra), safe="")
self.assertEqual(expected, outbound(url)["streamSettings"]["xhttpSettings"]["extra"])
fresh = dict(new, sessionIDTable="0123456789abcdef", sessionIDLength=16)
url = XHTTP + "&extra=" + quote(json.dumps(fresh), safe="")
self.assertEqual(fresh, outbound(url)["streamSettings"]["xhttpSettings"]["extra"])
def test_xhttp_conflicting_session_names_are_rejected(self):
extra = {"sessionPlacement": "header", "sessionIDPlacement": "cookie"}
url = XHTTP + "&extra=" + quote(json.dumps(extra), safe="")
with self.assertRaisesRegex(ProxyError, "противоречивые"):
outbound(url)
def test_alpn_values(self):
for value in ("http/1.1", "h2", "h3", "h2,h3"):
with self.subTest(value):
@@ -349,12 +446,25 @@ class VlessProfileTest(unittest.TestCase):
"grpc": REALITY.replace("type=tcp", "type=grpc"),
"missing pbk": REALITY.replace("&pbk=SYNTHETIC_PUBLIC_KEY", ""),
"xhttp field in reality": REALITY + "&path=%2Fp",
"xhttp padding in reality": REALITY + "&x_padding_bytes=100-1000",
"duplicate padding": XHTTP + "&x_padding_bytes=100&x_padding_bytes=200",
"canonical padding in reality": REALITY + "&xPaddingBytes=100",
"ech in reality": REALITY + "&ech=secret",
"pqv in tls": XHTTP + "&pqv=secret",
"spx in tls": XHTTP + "&spx=%2F",
"other core hint": REALITY + "&support-x25519mlkem768=false",
"empty sni": XHTTP.replace("sni=cdn.example.invalid", "sni="),
"empty path": XHTTP.replace("path=%2Fp", "path="),
"missing reality fingerprint": REALITY.replace("&fp=chrome", ""),
"finalmask not object": XHTTP + "&fm=%5B%5D",
"finalmask invalid json": XHTTP + "&fm=%7B",
"xhttp with reality": XHTTP.replace("security=tls", "security=reality"),
"flow over xhttp without encryption": XHTTP + "&flow=xtls-rprx-vision",
"unknown encryption": HIDDIFY_XHTTP.replace("mlkem768x25519plus.", "aes-128-gcm."),
"unknown alpn": XHTTP + "&alpn=h4",
"empty alpn item": XHTTP + "&alpn=h2%2C",
"short pcs": XHTTP + "&pcs=abc",
"space inside pcs": XHTTP + "&pcs=" + "aa" * 16 + "%20" + "aa" * 16,
"empty vcn item": XHTTP + "&vcn=a.example%2C",
"extra not an object": XHTTP + "&extra=%5B1%5D",
"extra not json": XHTTP + "&extra=%7Bx",
@@ -383,7 +493,7 @@ class XrayInstallTest(unittest.TestCase):
self.archive = self.make_archive(self.fake_xray("installed"))
@staticmethod
def fake_xray(label, version="26.3.27"):
def fake_xray(label, version="26.10.10"):
return "#!/bin/sh\n[ \"$1\" = version ] && echo 'Xray {} ({})'\n".format(
version, label
).encode("ascii")
@@ -440,7 +550,7 @@ class XrayInstallTest(unittest.TestCase):
self.assertIn("установлен", output)
self.assertEqual(0o755, self.destination.stat().st_mode & 0o777)
self.assertIn(b"installed", self.destination.read_bytes())
self.assertTrue(calls[0].endswith("/v26.3.27/Xray-linux-64.zip"))
self.assertTrue(calls[0].endswith("/v26.10.10/Xray-linux-64.zip"))
self.assertEqual([calls[0], calls[0] + ".dgst"], calls)
self.assertEqual(["xray"], [path.name for path in self.destination.parent.iterdir()])
+20 -2
View File
@@ -34,7 +34,7 @@ READY = "остановка: Ctrl+C или proxy-client stop"
# совпадает с разрешённым путём к xray, как у настоящего бинарника.
# «xray version» и «xray run ...» запускают одноимённые скрипты из рабочего
# каталога теста.
FAKE_VERSION = "print('Xray 26.3.27 (fake)')\n"
FAKE_VERSION = "print('Xray 26.10.10 (fake)')\n"
FAKE_RUN = r'''import json, os, socket, sys
config_path = sys.argv[-1]
@@ -219,6 +219,24 @@ class ManagedXrayTest(unittest.TestCase):
self.assertFalse(pid_alive(xray_pid))
self.assert_no_state_files()
def test_xhttp_export_with_empty_host_and_padding_starts(self):
extra = {"xPaddingBytes": "100-1000", "downloadSettings": {"address": "cdn.example"}}
profile = PROFILE.replace("&host=host.example", "&host=")
base, fragment = profile.split("#", 1)
profile = base + "&x_padding_bytes=100-1000&extra="
profile += quote(json.dumps(extra), safe="") + "#" + fragment
self.write_profile(self.profile, text=profile)
process = self.start()
config = json.loads((self.state_dir / "client.json").read_text())
stream = config["outbounds"][0]["streamSettings"]
self.assertEqual("", stream["xhttpSettings"]["host"])
self.assertEqual(extra, stream["xhttpSettings"]["extra"])
process.send_signal(signal.SIGINT)
stdout, stderr = self.finish(process)
self.assertEqual(0, process.returncode, stderr)
self.assert_no_state_files()
self.assert_no_secrets(stdout + stderr)
def test_stop_finishes_xray_left_after_killed_supervisor(self):
process = self.start()
xray_pid = self.xray_pid()
@@ -321,7 +339,7 @@ class ManagedXrayTest(unittest.TestCase):
(self.root / "version").write_text("print('Xray 26.3.26 (fake)')\n", encoding="utf-8")
result = self.run_cli("start")
self.assertEqual(1, result.returncode)
self.assertIn("не сообщил версию Xray 26.3.27", result.stderr)
self.assertIn("не сообщил версию Xray 26.10.10", result.stderr)
self.assert_no_state_files()
def test_replaced_xray_file_is_still_recognised_and_stopped(self):