diff --git a/docs/specs/2026-07-15-proxy-client-design.md b/docs/specs/2026-07-15-proxy-client-design.md index 4f4c823..19b6221 100644 --- a/docs/specs/2026-07-15-proxy-client-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -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` принимает только ту же версию и никогда ничего не скачивает. diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index f96ca43..aff728f 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -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": diff --git a/proxy-client/README.md b/proxy-client/README.md index a47c510..d08c1e7 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -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. ### Файлы и безопасность diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index 2b12006..f0adede 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -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()]) diff --git a/tests/test_proxy_client_managed.py b/tests/test_proxy_client_managed.py index 287a69d..be6fe22 100644 --- a/tests/test_proxy_client_managed.py +++ b/tests/test_proxy_client_managed.py @@ -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):