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

+8 -1
View File
@@ -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` принимает только ту же версию и никогда
ничего не скачивает.
+91 -20
View File
@@ -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
View File
@@ -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
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):