Files
dotfiles/tests/test_proxy_client.py
T
ddadmin a30e2eb939 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.
2026-10-12 16:21:16 -04:00

715 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""proxy-client без управляемого Xray: команда, машинный конфиг, разбор ссылки, install."""
import hashlib
import json
import os
import runpy
import socket
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
from urllib.parse import quote
REPO_ROOT = Path(__file__).resolve().parents[1]
CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client"
MODULE = runpy.run_path(str(CLI))
ProxyError = MODULE["ProxyError"]
def closed_port():
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class ExternalProxyCliTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.config_dir = self.root / "proxy-client"
self.environment = dict(
os.environ,
XDG_CONFIG_HOME=str(self.root),
XDG_STATE_HOME=str(self.root / "state"),
PYTHONDONTWRITEBYTECODE="1",
)
self.listener = socket.socket()
self.addCleanup(self.listener.close)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(16)
self.port = self.listener.getsockname()[1]
def write_config(self, port=None, proxy_type="external", address="127.0.0.1",
no_proxy_extra=".example.test"):
self.config_dir.mkdir(exist_ok=True)
(self.config_dir / "config").write_text(
"[proxy]\ntype = {}\nname = Test proxy\naddress = {}\nport = {}\n"
"no_proxy_extra = {}\n".format(
proxy_type, address, port or self.port, no_proxy_extra
),
encoding="utf-8",
)
def run_cli(self, *arguments, **environment):
return subprocess.run(
[str(CLI)] + list(arguments),
env=dict(self.environment, **environment),
text=True,
capture_output=True,
timeout=15,
check=False,
)
def test_no_subcommand_returns_2_without_side_effects(self):
result = self.run_cli()
self.assertEqual(2, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("укажите явную подкоманду", result.stderr)
self.assertEqual([], list(self.root.iterdir()))
def test_old_python_gets_clear_error(self):
code = (
"import runpy, sys; sys.version_info = (3, 7, 9); "
"runpy.run_path(sys.argv[1], run_name='__main__')"
)
result = subprocess.run(
[sys.executable, "-c", code, str(CLI)], text=True, capture_output=True, check=False
)
self.assertEqual(1, result.returncode)
self.assertIn("требует Python 3.8", result.stderr)
def test_status_env_and_run_against_reachable_proxy(self):
self.write_config(no_proxy_extra=" .example.test , git.example.test ")
status = self.run_cli("status")
self.assertEqual(0, status.returncode, status.stderr)
self.assertIn("Test proxy (external) доступен", status.stdout)
env = self.run_cli("env")
proxy_url = "http://127.0.0.1:{}".format(self.port)
socks_url = "socks5://127.0.0.1:{}".format(self.port)
no_proxy = "127.0.0.1,localhost,::1,[::1],.example.test,git.example.test"
self.assertEqual(0, env.returncode, env.stderr)
self.assertEqual(
[
"HTTP_PROXY=" + proxy_url,
"HTTPS_PROXY=" + proxy_url,
"ALL_PROXY=" + socks_url,
"NO_PROXY=" + no_proxy,
"http_proxy=" + proxy_url,
"https_proxy=" + proxy_url,
"all_proxy=" + socks_url,
"no_proxy=" + no_proxy,
],
env.stdout.splitlines(),
)
code = (
"import json, os, sys; print(json.dumps([sys.argv[1:], "
"os.environ['http_proxy']])); sys.exit(42)"
)
run = self.run_cli("run", "--", sys.executable, "-c", code, "a b", "--", "--flag")
self.assertEqual(42, run.returncode, run.stderr)
self.assertEqual([["a b", "--", "--flag"], proxy_url], json.loads(run.stdout))
killed = self.run_cli(
"run", "--", sys.executable, "-c",
"import os, signal; os.kill(os.getpid(), signal.SIGTERM)",
)
self.assertEqual(143, killed.returncode, killed.stderr)
def test_unreachable_proxy_prints_nothing_and_runs_nothing(self):
self.write_config(port=closed_port())
marker = self.root / "child-ran"
for command in (("env",), ("run", "--", "touch", str(marker))):
with self.subTest(command=command):
result = self.run_cli(*command)
self.assertEqual(1, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("недоступен", result.stderr)
self.assertFalse(marker.exists())
def test_xray_type_without_running_xray_fails_without_fallback(self):
self.write_config(proxy_type="xray")
marker = self.root / "child-ran"
for command in (("status",), ("env",), ("run", "--", "touch", str(marker))):
with self.subTest(command=command):
result = self.run_cli(*command)
self.assertEqual(1, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("не запущен", result.stderr)
self.assertFalse(marker.exists())
def test_start_refuses_external_type(self):
self.write_config()
result = self.run_cli("start")
self.assertEqual(1, result.returncode)
self.assertIn("только для type=xray", result.stderr)
def test_missing_config_uses_hiddify_default(self):
# Доступен ли Hiddify на этой машине, неважно: и отчёт о доступности,
# и ошибка называют адрес по умолчанию.
result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="0.05")
output = result.stdout + result.stderr
self.assertIn("Hiddify", output)
self.assertIn("hproxy.local:12334", output)
def test_broken_config_symlink_is_an_error(self):
self.config_dir.mkdir()
(self.config_dir / "config").symlink_to(self.root / "missing")
result = self.run_cli("status")
self.assertEqual(1, result.returncode)
self.assertIn("отсутствующий файл", result.stderr)
def test_run_usage_errors(self):
self.write_config()
missing = self.run_cli("run", "--", "definitely-no-such-command-xyz")
self.assertEqual(127, missing.returncode)
self.assertIn("не найдена в PATH", missing.stderr)
for command in (("run",), ("run", "true"), ("run", "--")):
with self.subTest(command=command):
self.assertIn("run -- COMMAND", self.run_cli(*command).stderr)
def test_invalid_preflight_timeout_is_rejected(self):
self.write_config()
result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="not-a-number")
self.assertEqual(1, result.returncode)
self.assertIn("положительным числом", result.stderr)
class MachineConfigCliTest(unittest.TestCase):
"""Машинный конфиг проверяем через команду: код выхода и путь в ошибке."""
VALID = (
"[proxy]\ntype = external\nname = Test\naddress = 127.0.0.1\n"
"port = 12334\nno_proxy_extra =\n"
)
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.path = self.root / "proxy-client/config"
self.path.parent.mkdir()
def start_with(self, text):
# start с type=external отказывает сразу после чтения конфига:
# сеть и Xray не нужны.
self.path.write_text(text, encoding="utf-8")
return subprocess.run(
[str(CLI), "start"],
env=dict(os.environ, XDG_CONFIG_HOME=str(self.root), PYTHONDONTWRITEBYTECODE="1"),
text=True,
capture_output=True,
timeout=15,
check=False,
)
def test_invalid_configs_are_rejected_with_path(self):
cases = {
"not ini": "not an ini\n",
"unknown section": self.VALID + "[other]\nkey = value\n",
"unknown key": self.VALID + "extra = value\n",
"duplicate key": self.VALID + "port = 12335\n",
"missing key": self.VALID.replace("name = Test\n", ""),
"bad type": self.VALID.replace("type = external", "type = socks"),
"empty name": self.VALID.replace("name = Test", "name = "),
"empty address": self.VALID.replace("address = 127.0.0.1", "address ="),
"port zero": self.VALID.replace("port = 12334", "port = 0"),
"port high": self.VALID.replace("port = 12334", "port = 65536"),
"port syntax": self.VALID.replace("port = 12334", "port = +80"),
"empty no-proxy item": self.VALID.replace(
"no_proxy_extra =", "no_proxy_extra = one.example,,two.example"
),
}
for address in ("http://proxy", "[::1]", "has space", "proxy/path", "user@proxy",
"прокси.example", "fe80::1%eth0"):
cases["address " + address] = self.VALID.replace("127.0.0.1", address)
for label, text in cases.items():
with self.subTest(label):
result = self.start_with(text)
self.assertEqual(1, result.returncode)
self.assertIn("Ошибка: машинный конфиг {}:".format(self.path), result.stderr)
def test_valid_addresses_are_accepted(self):
for address in ("127.0.0.1", "2001:db8::1", "proxy-1.example", "trailing.example."):
with self.subTest(address):
result = self.start_with(self.VALID.replace("127.0.0.1", address))
self.assertIn("только для type=xray", result.stderr)
UUID = "11111111-2222-4333-8444-555555555555"
ENCRYPTION = "mlkem768x25519plus.native.0rtt.SYNTHETIC_CLIENT_KEY"
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"
).format(UUID)
XHTTP = (
"vless://{}@example.invalid:443?type=xhttp&security=tls&encryption=none"
"&sni=cdn.example.invalid&fp=chrome&host=cdn.example.invalid&path=%2Fp"
).format(UUID)
# Новый формат Hiddify: «/» перед «?», VLESS-шифрование и flow поверх XHTTP.
HIDDIFY_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, quote('{"downloadSettings":{"address":"cdn.example.invalid"}}', safe=""),
ENCRYPTION)
def outbound(url):
profile = MODULE["parse_vless"](url)
return MODULE["build_xray_config"](profile, "127.0.0.1", 12334)["outbounds"][0]
class VlessProfileTest(unittest.TestCase):
def test_reality_mapping(self):
result = outbound(REALITY + "&host=ignored.example&alpn=h2")
self.assertEqual("xtls-rprx-vision", result["settings"]["flow"])
self.assertEqual(
{
"network": "raw",
"security": "reality",
"realitySettings": {
"serverName": "front.example.invalid",
"fingerprint": "chrome",
"password": "SYNTHETIC_PUBLIC_KEY",
"shortId": "SYNTHETIC_SHORT_ID",
},
},
result["streamSettings"],
)
def test_xhttp_mapping_with_all_optional_fields(self):
pcs = "a" * 64 + "%2C" + "B" * 64
url = XHTTP + "&alpn=http%2F1.1%2Ch2&mode=auto&pcs={}&vcn=a.example%2Cb.example" \
"&extra=%7B%22x%22%3A1%7D&core=xray".format(pcs)
stream = outbound(url)["streamSettings"]
self.assertEqual(
{"host": "cdn.example.invalid", "path": "/p", "mode": "auto", "extra": {"x": 1}},
stream["xhttpSettings"],
)
self.assertEqual(
{
"serverName": "cdn.example.invalid",
"fingerprint": "chrome",
"alpn": ["http/1.1", "h2"],
"pinnedPeerCertSha256": "a" * 64 + "," + "B" * 64,
"verifyPeerCertByName": "a.example,b.example",
},
stream["tlsSettings"],
)
def test_hiddify_xhttp_with_vless_encryption(self):
result = outbound(HIDDIFY_XHTTP)
self.assertEqual(ENCRYPTION, result["settings"]["encryption"])
self.assertEqual("xtls-rprx-vision", result["settings"]["flow"])
self.assertEqual("192.0.2.1", result["settings"]["address"])
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):
tls = outbound(XHTTP + "&alpn=" + quote(value, safe=""))["streamSettings"][
"tlsSettings"
]
self.assertEqual(value.split(","), tls["alpn"])
def test_legacy_insecure_is_accepted_ignored_and_flagged_when_true(self):
cases = (
("", False),
("&allowInsecure=false", False),
("&allowInsecure=0&insecure=0", False),
("&insecure=1", True),
("&allowInsecure=TRUE&insecure=false", True),
)
for base in (XHTTP, REALITY):
for fields, flagged in cases:
with self.subTest(base=base[-20:], fields=fields):
profile = MODULE["parse_vless"](base + fields)
self.assertEqual(flagged, profile.insecure)
config = json.dumps(MODULE["build_xray_config"](profile, "127.0.0.1", 1))
self.assertNotIn("nsecure", config)
def test_rejected_links(self):
cases = {
"unknown parameter": REALITY + "&mystery=value",
"duplicate parameter": REALITY + "&sni=again.example",
"headerType http": REALITY.replace("headerType=none", "headerType=http"),
"other flow": REALITY.replace("flow=xtls-rprx-vision", "flow=other"),
"bad percent-encoding": REALITY.replace("sni=front.example.invalid", "sni=bad%FF"),
"non-root path": REALITY.replace(":443?", ":443/path?"),
"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",
"bad uuid": REALITY.replace(UUID, "not-a-uuid"),
"not vless": REALITY.replace("vless://", "vmess://"),
}
for label, url in cases.items():
with self.subTest(label):
with self.assertRaises(ProxyError):
MODULE["parse_vless"](url)
class XrayInstallTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.home = self.root / "home"
self.home.mkdir()
self.destination = self.home / ".local/bin/xray"
self.environment = {
"HOME": str(self.home),
"XDG_STATE_HOME": str(self.root / "state"),
"PATH": str(self.destination.parent),
}
self.archive = self.make_archive(self.fake_xray("installed"))
@staticmethod
def fake_xray(label, version="26.10.10"):
return "#!/bin/sh\n[ \"$1\" = version ] && echo 'Xray {} ({})'\n".format(
version, label
).encode("ascii")
def make_archive(self, content, member="xray"):
path = self.root / "archive-{}.zip".format(hashlib.sha256(content).hexdigest()[:8])
with zipfile.ZipFile(str(path), "w") as archive:
archive.writestr(member, content)
return path.read_bytes()
def downloader(self, archive=None, digest=None):
archive = self.archive if archive is None else archive
if digest is None:
digest = (
"MD5= {}\nSHA1= {}\nSHA2-256= {}\n".format(
"1" * 32, "2" * 40, hashlib.sha256(archive).hexdigest()
)
)
calls = []
def download(url, destination):
calls.append(url)
if url.endswith(".dgst"):
destination.write_text(digest, encoding="ascii")
else:
destination.write_bytes(archive)
return download, calls
def install(self, replace=False, downloader=None, machine="x86_64", path=None):
if downloader is None:
downloader = self.downloader()[0]
environment = dict(self.environment)
if path is not None:
environment["PATH"] = path
with mock.patch.dict(os.environ, environment), \
mock.patch("platform.machine", return_value=machine), \
mock.patch("sys.stdout") as stdout:
MODULE["command_install"](replace, downloader)
return "".join(call.args[0] for call in stdout.write.call_args_list)
def write_destination(self, content=b"old sentinel", executable=True):
self.destination.parent.mkdir(parents=True, exist_ok=True)
self.destination.write_bytes(content)
self.destination.chmod(0o755 if executable else 0o644)
def assert_destination_kept(self, content=b"old sentinel"):
self.assertEqual(content, self.destination.read_bytes())
self.assertEqual([], list(self.destination.parent.glob(".xray-install-*")))
def test_fresh_install(self):
downloader, calls = self.downloader()
output = self.install(downloader=downloader)
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.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()])
def test_architecture_selects_asset(self):
downloader, calls = self.downloader()
self.install(downloader=downloader, machine="aarch64")
self.assertTrue(calls[0].endswith("/Xray-linux-arm64-v8a.zip"))
with self.assertRaisesRegex(ProxyError, "не поддерживается"):
self.install(replace=True, downloader=self.fail_download, machine="riscv64")
@staticmethod
def fail_download(_url, _destination):
raise AssertionError("сеть не должна использоваться")
def test_digest_is_compared_case_insensitively(self):
digest = "SHA2-256= {}\n".format(hashlib.sha256(self.archive).hexdigest().upper())
self.install(downloader=self.downloader(digest=digest)[0])
self.assertTrue(self.destination.exists())
def test_failures_keep_old_binary_and_remove_staging(self):
cases = {
"wrong hash": self.downloader(digest="SHA2-256= " + "0" * 64 + "\n")[0],
"no SHA2-256 line": self.downloader(digest="MD5= " + "1" * 32 + "\n")[0],
"corrupt zip": self.downloader(archive=b"not a zip")[0],
"no xray in zip": self.downloader(
archive=self.make_archive(b"data", member="other")
)[0],
"wrong version": self.downloader(
archive=self.make_archive(self.fake_xray("old", "26.3.26"))
)[0],
}
def interrupted(_url, destination):
destination.write_bytes(b"partial")
raise KeyboardInterrupt
for label, downloader in cases.items():
with self.subTest(label):
self.write_destination()
with self.assertRaises(ProxyError):
self.install(replace=True, downloader=downloader)
self.assert_destination_kept()
with self.assertRaises(KeyboardInterrupt):
self.install(replace=True, downloader=interrupted)
self.assert_destination_kept()
def test_existing_right_version_is_kept_without_network(self):
self.write_destination(self.fake_xray("existing"))
output = self.install(downloader=self.fail_download)
self.assertIn("уже установлен", output)
self.assert_destination_kept(self.fake_xray("existing"))
def test_other_existing_file_needs_replace(self):
for content, executable in ((b"broken", True), (self.fake_xray("x"), False)):
with self.subTest(content=content, executable=executable):
self.write_destination(content, executable)
with self.assertRaisesRegex(ProxyError, "--replace"):
self.install(downloader=self.fail_download)
self.install(replace=True)
self.assertIn(b"installed", self.destination.read_bytes())
def test_replace_does_not_write_through_symlink(self):
target = self.root / "target"
target.write_bytes(b"target sentinel")
self.destination.parent.mkdir(parents=True)
self.destination.symlink_to(target)
with self.assertRaisesRegex(ProxyError, "--replace"):
self.install(downloader=self.fail_download)
self.install(replace=True)
self.assertFalse(self.destination.is_symlink())
self.assertEqual(b"target sentinel", target.read_bytes())
def test_directory_destination_is_refused(self):
self.destination.mkdir(parents=True)
for replace in (False, True):
with self.subTest(replace=replace):
with self.assertRaisesRegex(ProxyError, "каталог"):
self.install(replace=replace, downloader=self.fail_download)
def test_path_that_misses_installed_xray_is_an_error(self):
competitor_dir = self.root / "competitor"
competitor_dir.mkdir()
(competitor_dir / "xray").write_bytes(self.fake_xray("competitor"))
(competitor_dir / "xray").chmod(0o755)
for path in (str(self.root), str(competitor_dir) + os.pathsep + str(self.destination.parent)):
with self.subTest(path=path):
with self.assertRaisesRegex(ProxyError, "PATH"):
self.install(path=path)
self.assertTrue(self.destination.is_file())
self.destination.unlink()
def test_install_under_running_xray_asks_to_restart_start(self):
# Роль работающего Xray играет сам тестовый процесс.
state_dir = self.root / "state/proxy-client"
state_dir.mkdir(parents=True)
(state_dir / "state.json").write_text(
json.dumps(
{
"supervisor_pid": os.getpid(),
"xray_pid": os.getpid(),
"xray_path": os.path.realpath("/proc/self/exe"),
"address": "127.0.0.1",
"port": 1,
}
),
encoding="utf-8",
)
self.write_destination(self.fake_xray("old", "26.3.26"))
output = self.install(replace=True)
self.assertIn("перезапустите proxy-client start", output)
self.assertIn(b"installed", self.destination.read_bytes())
def test_install_does_not_read_machine_config(self):
self.write_destination(self.fake_xray("existing"))
config = self.root / "config/proxy-client"
config.mkdir(parents=True)
(config / "config").write_text("not an ini\n", encoding="ascii")
environment = dict(os.environ, XDG_CONFIG_HOME=str(self.root / "config"))
environment.update(self.environment)
environment["PATH"] += os.pathsep + os.environ["PATH"]
result = subprocess.run(
[str(CLI), "install"], env=environment, text=True, capture_output=True, check=False
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("уже установлен", result.stdout)
class PortCheckTest(unittest.TestCase):
address = MODULE["ipaddress"].ip_address("127.0.0.1")
@staticmethod
def listener():
# Как у Xray: Go ставит SO_REUSEADDR на слушающий сокет.
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
sock.listen()
return sock
def test_port_is_free_right_after_xray_closed_its_connections(self):
listener = self.listener()
port = listener.getsockname()[1]
client = socket.create_connection(("127.0.0.1", port))
accepted, _peer = listener.accept()
# После Ctrl+C Xray первым закрывает соединения, и ядро держит их
# «хвосты» на локальном порту ещё около минуты.
accepted.close()
listener.close()
try:
MODULE["check_port_available"](self.address, port)
finally:
client.close()
def test_live_listener_is_reported_as_busy(self):
with self.listener() as listener:
with self.assertRaisesRegex(ProxyError, "занят"):
MODULE["check_port_available"](self.address, listener.getsockname()[1])
if __name__ == "__main__":
unittest.main()