Files
dotfiles/tests/test_proxy_client.py
T
ddadminandClaude Opus 5.5 a5db265f47 refactor(proxy-client): убрать избыточную защиту из CLI и тестов
- Проверка «тот ли процесс»: /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>
2026-09-28 09:34:57 -04:00

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()