- Проверка «тот ли процесс»: /proc/PID/exe для Xray и cmdline для супервизора вместо boot_id, времени старта и разбора argv; нет больше состояния «не удалось подтвердить, удалите файлы вручную». - Установка Xray: SHA-256 и атомарная замена остаются; без ограничений размера, строгого разбора .dgst и блокировки на время установки. - Разбор ссылки: extra — обычный json.loads; allowInsecure/insecure игнорируются с предупреждением. - Ошибки о файлах называют полный путь и готовую команду (chmod 600 для профиля, rm для битого state.json). - Тесты: разбор ссылок — табличные без запуска процессов, по одному запуску на сценарий управляемого Xray; без подмены внутренних функций. Код 1569 → 925 строк, тесты 2479 → 895, прогон 63 → 9 с. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
599 lines
26 KiB
Python
599 lines
26 KiB
Python
#!/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):
|
|
result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="0.05")
|
|
self.assertIn("Hiddify (hproxy.local:12334)", result.stderr)
|
|
|
|
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 MachineConfigTest(unittest.TestCase):
|
|
VALID = (
|
|
"[proxy]\ntype = external\nname = Test\naddress = 127.0.0.1\n"
|
|
"port = 12334\nno_proxy_extra =\n"
|
|
)
|
|
|
|
def parse(self, text):
|
|
return MODULE["parse_config"](text)
|
|
|
|
def test_valid_config(self):
|
|
config = self.parse(self.VALID.replace("no_proxy_extra =", "no_proxy_extra = a, b"))
|
|
self.assertEqual(
|
|
("external", "Test", "127.0.0.1", 12334, ("a", "b")),
|
|
(config.proxy_type, config.name, config.address, config.port, config.no_proxy_extra),
|
|
)
|
|
|
|
def test_invalid_structure_and_values(self):
|
|
cases = {
|
|
"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 label, text in cases.items():
|
|
with self.subTest(label):
|
|
# Ошибки синтаксиса INI приходят от configparser; load_config
|
|
# добавляет к ним путь к файлу.
|
|
with self.assertRaises((ProxyError, MODULE["configparser"].Error)):
|
|
self.parse(text)
|
|
|
|
def test_addresses(self):
|
|
for address in ("127.0.0.1", "2001:db8::1", "proxy-1.example", "trailing.example."):
|
|
with self.subTest(address):
|
|
self.assertEqual(
|
|
address, self.parse(self.VALID.replace("127.0.0.1", address)).address
|
|
)
|
|
for address in ("http://proxy", "[::1]", "has space", "proxy/path", "user@proxy",
|
|
"прокси.example", "fe80::1%eth0"):
|
|
with self.subTest(address):
|
|
with self.assertRaisesRegex(ProxyError, "address"):
|
|
self.parse(self.VALID.replace("127.0.0.1", address))
|
|
|
|
def test_load_error_names_config_file(self):
|
|
with tempfile.TemporaryDirectory() as root:
|
|
path = Path(root) / "proxy-client/config"
|
|
path.parent.mkdir()
|
|
path.write_text("not an ini\n", encoding="utf-8")
|
|
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": root}):
|
|
with self.assertRaisesRegex(ProxyError, str(path)):
|
|
MODULE["load_config"]()
|
|
|
|
|
|
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_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 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",
|
|
"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.3.27"):
|
|
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.3.27/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"),
|
|
}
|
|
),
|
|
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()
|