diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index a2e305e..4fe981f 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -442,11 +442,12 @@ def parse_vless(url): parsed = urlsplit(url) except ValueError: raise ProxyError("неверная VLESS-ссылка") + # Новые ссылки Hiddify пишут «/» перед «?»: ...@SERVER:PORT/?type=... if ( parsed.scheme != "vless" or not parsed.netloc or parsed.password is not None - or parsed.path + or parsed.path not in ("", "/") ): raise ProxyError("ожидалась одна ссылка vless://UUID@SERVER:PORT") try: @@ -464,8 +465,11 @@ def parse_vless(url): validate_address(server) query = parse_query(parsed.query) required(query, "type", "security", "encryption", "sni", "fp") - if query["encryption"] != "none": - raise ProxyError("поддерживается только encryption=none") + # Кроме none, Xray принимает VLESS-шифрование вида + # mlkem768x25519plus.РЕЖИМ.RTT.КЛЮЧ...; подробности проверит xray run -test. + encrypted = query["encryption"] != "none" + if encrypted and not query["encryption"].startswith("mlkem768x25519plus."): + raise ProxyError("поддерживается только encryption=none или mlkem768x25519plus") if query.get("flow") not in (None, "xtls-rprx-vision"): raise ProxyError("поддерживается только flow=xtls-rprx-vision") if query.get("headerType", "none") != "none": @@ -485,7 +489,8 @@ def parse_vless(url): raise ProxyError("параметры профиля несовместимы с TCP/REALITY") elif query["type"] == "xhttp" and query["security"] == "tls": required(query, "host", "path") - if "flow" in query or "pbk" in query or "sid" in query: + # Vision поверх XHTTP работает только внутри VLESS-шифрования. + if ("flow" in query and not encrypted) or "pbk" in query or "sid" in query: raise ProxyError("параметры профиля несовместимы с XHTTP/TLS") if "pcs" in query: hashes = list_value(query["pcs"]) @@ -1188,6 +1193,8 @@ def safe_summary(profile, local_port): ), flush=True, ) + if profile.query["encryption"] != "none": + print("VLESS-шифрование: mlkem768x25519plus", flush=True) print("UUID и ключи: скрыты", flush=True) diff --git a/proxy-client/README.md b/proxy-client/README.md index b5b9a3c..0d2617f 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -345,6 +345,12 @@ source ~/.config/proxy-client/shell.bash повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN `http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. +Кроме `encryption=none`, принимается VLESS-шифрование +`encryption=mlkem768x25519plus.…` из новых ссылок Hiddify; остальную часть +значения проверяет сам Xray. Только с таким шифрованием XHTTP допускает +`flow=xtls-rprx-vision`. Косая черта перед параметрами (`…:443/?type=…`) +допустима, другой путь в ссылке — нет. + Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и `insecure` не переносит. Истинное legacy-значение даёт предупреждение, но обычная проверка сертификата остаётся включённой. diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index cd0939d..7d7ce38 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -1231,5 +1231,76 @@ class XrayInstallTest(unittest.TestCase): self.assertIn("уже установлен", result.stdout) +class VlessProfileTest(unittest.TestCase): + UUID = "11111111-2222-4333-8444-555555555555" + ENCRYPTION = "mlkem768x25519plus.native.0rtt.SYNTHETIC_CLIENT_KEY" + EXTRA = ( + "%7B%22downloadSettings%22%3A%7B%22address%22%3A%22cdn.example.invalid%22" + "%2C%22port%22%3A443%2C%22network%22%3A%22xhttp%22%7D%7D" + ) + OLD_REALITY = ( + "vless://{}@example.invalid:443?type=tcp&security=reality&encryption=none" + "&flow=xtls-rprx-vision&sni=front.example.invalid&fp=chrome" + "&pbk=SYNTHETIC_PUBLIC_KEY&sid=SYNTHETIC_SHORT_ID&headerType=none#old" + ).format(UUID) + OLD_XHTTP = ( + "vless://{}@example.invalid:443?type=xhttp&security=tls&encryption=none" + "&sni=cdn.example.invalid&fp=chrome&host=cdn.example.invalid&path=%2Fp" + "&alpn=h2&mode=auto" + ).format(UUID) + # Формат Hiddify с VLESS-шифрованием: «/» перед «?» и flow поверх XHTTP. + NEW_XHTTP = ( + "vless://{}@192.0.2.1:443/?hiddify=1&fp=chrome&sni=cdn.example.invalid" + "&security=tls&alpn=h3&type=xhttp&mode=auto&path=%2Fp" + "&host=cdn.example.invalid&extra={}&flow=xtls-rprx-vision" + "&encryption={}#xhttp%20vless%20QUIC" + ).format(UUID, EXTRA, ENCRYPTION) + + def setUp(self): + self.module = runpy.run_path(str(CLI)) + + def outbound(self, url): + profile = self.module["parse_vless"](url) + config = self.module["build_xray_config"](profile, "127.0.0.1", 12334) + return config["outbounds"][0] + + def test_old_formats_still_parse(self): + reality = self.outbound(self.OLD_REALITY) + self.assertEqual("none", reality["settings"]["encryption"]) + self.assertEqual("reality", reality["streamSettings"]["security"]) + + xhttp = self.outbound(self.OLD_XHTTP) + self.assertEqual("none", xhttp["settings"]["encryption"]) + self.assertNotIn("flow", xhttp["settings"]) + self.assertEqual(["h2"], xhttp["streamSettings"]["tlsSettings"]["alpn"]) + + def test_new_hiddify_xhttp_with_vless_encryption_parses(self): + outbound = self.outbound(self.NEW_XHTTP) + settings = outbound["settings"] + stream = outbound["streamSettings"] + self.assertEqual(self.ENCRYPTION, settings["encryption"]) + self.assertEqual("xtls-rprx-vision", settings["flow"]) + self.assertEqual("192.0.2.1", settings["address"]) + self.assertEqual(["h3"], stream["tlsSettings"]["alpn"]) + self.assertEqual( + "cdn.example.invalid", + stream["xhttpSettings"]["extra"]["downloadSettings"]["address"], + ) + + def test_incompatible_profiles_are_still_rejected(self): + cases = { + "flow over XHTTP without encryption": self.OLD_XHTTP + + "&flow=xtls-rprx-vision", + "unknown encryption": self.NEW_XHTTP.replace( + "mlkem768x25519plus.", "aes-128-gcm." + ), + "non-root path": self.NEW_XHTTP.replace(":443/?", ":443/sub?"), + } + for label, url in cases.items(): + with self.subTest(label=label): + with self.assertRaises(self.module["ProxyError"]): + self.module["parse_vless"](url) + + if __name__ == "__main__": unittest.main()