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
@@ -156,6 +156,13 @@ Xray всё равно можно остановить.
|
||||
повторный query-параметр — отказ: он может менять смысл соединения, и его
|
||||
нужно добавить в разбор явно. Остальное проверяет `xray run -test`.
|
||||
|
||||
Разбор следует [спецификации ссылок Xray](https://github.com/XTLS/Xray-core/discussions/716):
|
||||
поддерживает `ech`, `pqv`, `spx`, `fm`, необязательные поля и значения по
|
||||
умолчанию. Дополнительные параметры XHTTP переносятся внутрь `extra`, чтобы
|
||||
ядро их не потеряло. Старые `sessionPlacement`/`sessionKey` преобразуются в
|
||||
`sessionIDPlacement`/`sessionIDKey`, в том числе для отдельного скачивания;
|
||||
противоречивые значения отклоняются. Полный список — в README пакета.
|
||||
|
||||
Hiddify Manager добавляет `allowInsecure`/`insecure` даже к профилям с
|
||||
нормальным сертификатом
|
||||
([Hiddify-Manager#5438](https://github.com/hiddify/Hiddify-Manager/issues/5438)),
|
||||
@@ -169,7 +176,7 @@ TLS-проверку задают `pcs` и `vcn`.
|
||||
### Установка Xray
|
||||
|
||||
`proxy-client install` ставит в `~/.local/bin/xray` официальный архив одного
|
||||
зафиксированного релиза (сейчас 26.3.27), проверяет SHA-256 по `.dgst` и
|
||||
зафиксированного релиза (сейчас 26.10.10), проверяет SHA-256 по `.dgst` и
|
||||
заменяет файл атомарно. Официальный установщик Xray не подходит: он требует
|
||||
root и создаёт systemd-службы. `start` принимает только ту же версию и никогда
|
||||
ничего не скачивает.
|
||||
|
||||
@@ -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":
|
||||
|
||||
+35
-3
@@ -354,13 +354,38 @@ source ~/.config/proxy-client/shell.bash
|
||||
`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом, его содержимое
|
||||
проверяет `xray run -test`.
|
||||
|
||||
Параметры ссылок передаются по [спецификации Xray](https://github.com/XTLS/Xray-core/discussions/716):
|
||||
|
||||
| В ссылке | В конфигурации Xray |
|
||||
|----------|---------------------|
|
||||
| `ech` | TLS `echConfigList` |
|
||||
| `pqv`, `spx` | REALITY `mldsa65Verify`, `spiderX` |
|
||||
| `fm` | `streamSettings.finalmask`, JSON-объект |
|
||||
| `x_padding_bytes`, `xPaddingBytes` | XHTTP `extra.xPaddingBytes` |
|
||||
| `scMaxEachPostBytes`, `scMinPostsIntervalMs`, `uplinkChunkSize` | Одноимённые поля XHTTP внутри `extra` |
|
||||
|
||||
Для XHTTP пустой или отсутствующий `host` допустим; при отсутствии `path`
|
||||
используется `/`. Имена `sessionPlacement` и `sessionKey` в `extra`, включая
|
||||
отдельное соединение скачивания, преобразуются в `sessionIDPlacement` и
|
||||
`sessionIDKey`. Если старое и новое имя задают разные значения, профиль
|
||||
отклоняется. Новые `sessionIDTable` и `sessionIDLength` передаются ядру.
|
||||
Параметры XHTTP с именами как в JSON имеют приоритет над `extra`, а
|
||||
`extra.xPaddingBytes` — над `x_padding_bytes`.
|
||||
|
||||
При отсутствии `encryption` используется `none`, `sni` — адрес сервера,
|
||||
TLS `fp` — `chrome`. Для REALITY `fp` и `pbk` обязательны, а `flow` и `sid`
|
||||
могут быть пустыми или отсутствовать; `type=raw` принимается как `type=tcp`.
|
||||
Пустые `ech`, `pqv`, `spx`, `pcs`, `vcn` допустимы. SHA-256 в `pcs` может
|
||||
содержать двоеточия и пробелы по краям. Метка `support-x25519mlkem768=true` в REALITY
|
||||
принимается для совместимости со ссылками 3x-ui и не создаёт поле в JSON Xray.
|
||||
|
||||
Кроме `encryption=none`, принимается VLESS-шифрование
|
||||
`encryption=mlkem768x25519plus.…` из новых ссылок Hiddify; остальную часть
|
||||
значения проверяет сам Xray. Только с таким шифрованием XHTTP допускает
|
||||
`flow=xtls-rprx-vision`. Косая черта перед параметрами (`…:443/?type=…`)
|
||||
допустима, другой путь в ссылке — нет.
|
||||
|
||||
Xray 26.3.27 использует `pcs` и `vcn`. Устаревшие `allowInsecure` и `insecure`
|
||||
Xray 26.10.10 использует `pcs` и `vcn`. Устаревшие `allowInsecure` и `insecure`
|
||||
принимаются с любым значением, но не переносятся: при значении `1` или `true`
|
||||
выводится предупреждение, а сертификат проверяется обычным способом.
|
||||
|
||||
@@ -371,10 +396,17 @@ Xray 26.3.27 использует `pcs` и `vcn`. Устаревшие `allowIns
|
||||
|
||||
### Проверяемая установка Xray
|
||||
|
||||
`proxy-client install` устанавливает Xray 26.3.27 в `~/.local/bin/xray`. Для
|
||||
`proxy-client install` устанавливает Xray 26.10.10 в `~/.local/bin/xray`. Для
|
||||
`x86_64`/`amd64` используется `Xray-linux-64.zip`, для `aarch64`/`arm64` —
|
||||
`Xray-linux-arm64-v8a.zip`; остальные архитектуры отклоняются.
|
||||
|
||||
[Выпуск 26.10.10](https://github.com/XTLS/Xray-core/releases/tag/v26.10.10)
|
||||
помечен как предварительный. Обновление уже установленного клиента:
|
||||
|
||||
```bash
|
||||
proxy-client install --replace
|
||||
```
|
||||
|
||||
CLI скачивает официальный архив и файл `.dgst`, сверяет SHA-256 и фактическую
|
||||
версию бинарника, а затем атомарно заменяет назначение. При любой ошибке старый
|
||||
файл остаётся на месте. Если нужная версия уже установлена, сеть не нужна и
|
||||
@@ -386,7 +418,7 @@ CLI скачивает официальный архив и файл `.dgst`, с
|
||||
Устанавливать можно и при работающем Xray: он продолжит работу со старым файлом,
|
||||
а `install` напомнит перезапустить `start`.
|
||||
|
||||
`start` использует первый `xray` из `PATH` и принимает только версию 26.3.27.
|
||||
`start` использует первый `xray` из `PATH` и принимает только версию 26.10.10.
|
||||
|
||||
### Файлы и безопасность
|
||||
|
||||
|
||||
+112
-2
@@ -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()])
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user