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:
1 parent
16e85139b9
commit
a30e2eb939
5 files changed
+266
-28
No files matched your search
@@ -44,7 +44,7 @@ DEFAULT_NO_PROXY_EXTRA = (
|
||||
"dementev.space",
|
||||
".dementev.space",
|
||||
)
|
||||
XRAY_VERSION = "26.3.27"
|
||||
XRAY_VERSION = "26.10.10"
|
||||
VERSION_LINE = re.compile(r"Xray {}(?: .*)?".format(re.escape(XRAY_VERSION)))
|
||||
XRAY_RELEASE_BASE_URL = (
|
||||
"https://github.com/XTLS/Xray-core/releases/download/v{}/".format(XRAY_VERSION)
|
||||
@@ -60,6 +60,11 @@ QUERY_KEYS = {
|
||||
"fp",
|
||||
"pbk",
|
||||
"sid",
|
||||
"pqv",
|
||||
"spx",
|
||||
"ech",
|
||||
"fm",
|
||||
"support-x25519mlkem768",
|
||||
"headerType",
|
||||
"host",
|
||||
"alpn",
|
||||
@@ -68,12 +73,18 @@ QUERY_KEYS = {
|
||||
"path",
|
||||
"mode",
|
||||
"extra",
|
||||
"x_padding_bytes",
|
||||
"xPaddingBytes",
|
||||
"scMaxEachPostBytes",
|
||||
"scMinPostsIntervalMs",
|
||||
"uplinkChunkSize",
|
||||
"pcs",
|
||||
"vcn",
|
||||
"allowInsecure",
|
||||
"insecure",
|
||||
}
|
||||
ALPN_VALUES = {"http/1.1", "h2", "h3"}
|
||||
XHTTP_RANGE_KEYS = {"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"}
|
||||
|
||||
|
||||
class ProxyError(Exception):
|
||||
@@ -90,7 +101,7 @@ class ProxyConfig:
|
||||
|
||||
|
||||
class VlessProfile:
|
||||
def __init__(self, server, port, profile_id, query, alpn, extra, insecure):
|
||||
def __init__(self, server, port, profile_id, query, alpn, extra, insecure, finalmask=None):
|
||||
self.server = server
|
||||
self.port = port
|
||||
self.profile_id = profile_id
|
||||
@@ -98,6 +109,7 @@ class VlessProfile:
|
||||
self.alpn = alpn
|
||||
self.extra = extra
|
||||
self.insecure = insecure
|
||||
self.finalmask = finalmask
|
||||
|
||||
@property
|
||||
def transport(self):
|
||||
@@ -316,6 +328,36 @@ def list_value(value):
|
||||
return items
|
||||
|
||||
|
||||
def json_object(value, name):
|
||||
try:
|
||||
result = json.loads(value)
|
||||
except ValueError:
|
||||
result = None
|
||||
if not isinstance(result, dict):
|
||||
raise ProxyError("{} должен быть JSON-объектом".format(name))
|
||||
return result
|
||||
|
||||
|
||||
def normalize_xhttp_settings(settings):
|
||||
result = dict(settings)
|
||||
# Xray 26.9.30 переименовал поля; старые имена ядро молча игнорирует.
|
||||
for old, new in (("sessionPlacement", "sessionIDPlacement"), ("sessionKey", "sessionIDKey")):
|
||||
if old in result:
|
||||
value = result.pop(old)
|
||||
if new in result and result[new] != value:
|
||||
raise ProxyError("противоречивые старые и новые параметры сессии XHTTP")
|
||||
result[new] = value
|
||||
if isinstance(result.get("extra"), dict):
|
||||
result["extra"] = normalize_xhttp_settings(result["extra"])
|
||||
if isinstance(result.get("downloadSettings"), dict):
|
||||
download = dict(result["downloadSettings"])
|
||||
for name in ("xhttpSettings", "splithttpSettings"):
|
||||
if isinstance(download.get(name), dict):
|
||||
download[name] = normalize_xhttp_settings(download[name])
|
||||
result["downloadSettings"] = download
|
||||
return result
|
||||
|
||||
|
||||
def parse_vless(url):
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
@@ -343,6 +385,14 @@ def parse_vless(url):
|
||||
raise ProxyError("профиль содержит неверные UUID, сервер или порт")
|
||||
validate_address(server)
|
||||
query = parse_query(parsed.query)
|
||||
query.setdefault("encryption", "none")
|
||||
query.setdefault("sni", server)
|
||||
if query.get("security") == "tls":
|
||||
query.setdefault("fp", "chrome")
|
||||
if query.get("type") == "raw":
|
||||
query["type"] = "tcp"
|
||||
if query.get("flow") == "":
|
||||
del query["flow"]
|
||||
required(query, "type", "security", "encryption", "sni", "fp")
|
||||
# Кроме none, Xray принимает VLESS-шифрование вида
|
||||
# mlkem768x25519plus.РЕЖИМ.RTT.КЛЮЧ...; подробности проверит xray run -test.
|
||||
@@ -357,44 +407,57 @@ def parse_vless(url):
|
||||
raise ProxyError("поддерживается только метаданные hiddify=1")
|
||||
if "core" in query and query["core"] != "xray":
|
||||
raise ProxyError("поддерживается только метаданные core=xray")
|
||||
# Xray 26.3.27 больше не умеет отключать проверку сертификата: поля
|
||||
if "support-x25519mlkem768" in query and query["support-x25519mlkem768"] != "true":
|
||||
raise ProxyError("поддерживается только метаданные support-x25519mlkem768=true")
|
||||
# Xray 26.10.10 больше не умеет отключать проверку сертификата: поля
|
||||
# принимаем, но не переносим.
|
||||
insecure = any(
|
||||
query.get(key, "").lower() in ("1", "true") for key in ("allowInsecure", "insecure")
|
||||
)
|
||||
alpn = None
|
||||
extra = None
|
||||
finalmask = json_object(query["fm"], "fm") if "fm" in query else None
|
||||
|
||||
if query["type"] == "tcp" and query["security"] == "reality":
|
||||
required(query, "flow", "pbk", "sid")
|
||||
if {"path", "mode", "extra", "pcs", "vcn", "core"}.intersection(query):
|
||||
required(query, "pbk")
|
||||
if ({"path", "mode", "extra", "x_padding_bytes", "pcs", "vcn", "core", "ech"}
|
||||
| XHTTP_RANGE_KEYS).intersection(query):
|
||||
raise ProxyError("параметры профиля несовместимы с TCP/REALITY")
|
||||
elif query["type"] == "xhttp" and query["security"] == "tls":
|
||||
required(query, "host", "path")
|
||||
query.setdefault("path", "/")
|
||||
required(query, "path")
|
||||
# Vision поверх XHTTP работает только внутри VLESS-шифрования.
|
||||
if ("flow" in query and not encrypted) or "pbk" in query or "sid" in query:
|
||||
if (("flow" in query and not encrypted)
|
||||
or {"pbk", "sid", "pqv", "spx", "support-x25519mlkem768"}.intersection(query)):
|
||||
raise ProxyError("параметры профиля несовместимы с XHTTP/TLS")
|
||||
if "pcs" in query:
|
||||
if query.get("pcs"):
|
||||
hashes = list_value(query["pcs"])
|
||||
if any(re.fullmatch(r"[0-9A-Fa-f]{64}", item) is None for item in hashes):
|
||||
if any(re.fullmatch(r"[0-9A-Fa-f]{64}", item.strip().replace(":", ""))
|
||||
is None for item in hashes):
|
||||
raise ProxyError("pcs должен содержать 32-байтные SHA-256 в hex")
|
||||
if "vcn" in query:
|
||||
if query.get("vcn"):
|
||||
list_value(query["vcn"])
|
||||
if "alpn" in query:
|
||||
alpn = list_value(query["alpn"])
|
||||
if any(item not in ALPN_VALUES for item in alpn):
|
||||
raise ProxyError("профиль содержит неподдерживаемый ALPN")
|
||||
if "extra" in query:
|
||||
try:
|
||||
extra = json.loads(query["extra"])
|
||||
except ValueError:
|
||||
extra = None
|
||||
if not isinstance(extra, dict):
|
||||
raise ProxyError("extra должен быть JSON-объектом")
|
||||
extra = json_object(query["extra"], "extra")
|
||||
if "x_padding_bytes" in query:
|
||||
if extra is None:
|
||||
extra = {}
|
||||
# При наличии extra Xray берёт padding только из него.
|
||||
extra.setdefault("xPaddingBytes", query["x_padding_bytes"])
|
||||
for key in XHTTP_RANGE_KEYS.intersection(query):
|
||||
if extra is None:
|
||||
extra = {}
|
||||
extra[key] = query[key]
|
||||
if extra is not None:
|
||||
extra = normalize_xhttp_settings(extra)
|
||||
else:
|
||||
raise ProxyError("поддерживаются только TCP/REALITY и XHTTP/TLS")
|
||||
|
||||
return VlessProfile(server, port, profile_id, query, alpn, extra, insecure)
|
||||
return VlessProfile(server, port, profile_id, query, alpn, extra, insecure, finalmask)
|
||||
|
||||
|
||||
def build_xray_config(profile, local_address, local_port):
|
||||
@@ -412,15 +475,19 @@ def build_xray_config(profile, local_address, local_port):
|
||||
"serverName": query["sni"],
|
||||
"fingerprint": query["fp"],
|
||||
"password": query["pbk"],
|
||||
"shortId": query["sid"],
|
||||
"shortId": query.get("sid", ""),
|
||||
}
|
||||
if "pqv" in query:
|
||||
reality["mldsa65Verify"] = query["pqv"]
|
||||
if "spx" in query:
|
||||
reality["spiderX"] = query["spx"]
|
||||
stream = {
|
||||
"network": "raw",
|
||||
"security": "reality",
|
||||
"realitySettings": reality,
|
||||
}
|
||||
else:
|
||||
xhttp = {"host": query["host"], "path": query["path"]}
|
||||
xhttp = {"host": query.get("host", ""), "path": query["path"]}
|
||||
if "mode" in query:
|
||||
xhttp["mode"] = query["mode"]
|
||||
if profile.extra is not None:
|
||||
@@ -432,12 +499,16 @@ def build_xray_config(profile, local_address, local_port):
|
||||
tls["pinnedPeerCertSha256"] = query["pcs"]
|
||||
if "vcn" in query:
|
||||
tls["verifyPeerCertByName"] = query["vcn"]
|
||||
if "ech" in query:
|
||||
tls["echConfigList"] = query["ech"]
|
||||
stream = {
|
||||
"network": "xhttp",
|
||||
"security": "tls",
|
||||
"xhttpSettings": xhttp,
|
||||
"tlsSettings": tls,
|
||||
}
|
||||
if profile.finalmask is not None:
|
||||
stream["finalmask"] = profile.finalmask
|
||||
return {
|
||||
"log": {"access": "none", "loglevel": "warning"},
|
||||
"inbounds": [
|
||||
@@ -716,7 +787,7 @@ def print_summary(profile, local_port):
|
||||
else:
|
||||
print(
|
||||
"XHTTP: Host {}, длина пути: {}, ALPN {}".format(
|
||||
query["host"], len(query["path"]), query.get("alpn", "не задан")
|
||||
query.get("host") or "не задан", len(query["path"]), query.get("alpn", "не задан")
|
||||
)
|
||||
)
|
||||
if query["encryption"] != "none":
|
||||
|
||||
Reference in new issue
Block a user