feat(xray-proxy): добавлен управляемый прокси для CLI

Зачем:
- отделить переносимый прокси-интерфейс от общих Bash-функций
- запускать агентские CLI через явный внешний или управляемый прокси

Что:
- добавлен stow-пакет xray-proxy с CLI, Bash-модулем и примерами
- реализованы строгий разбор VLESS и управляемый запуск Xray
- добавлены проверки процесса, порта, сигналов и отсутствия утечек
- сохранены спецификация и очередь следующих тикетов

Проверка:
- python3 tests/test_xray_proxy.py — 22/22
- python3 tests/test_xray_proxy_managed.py — 16/16
- bash tests/test_proxy_launchers.sh — 9/9
- bash tests/test_install.sh
- bash -n, Python 3.8 grammar, git diff --check
This commit is contained in:
ddadmin committed 2026-07-20 00:00:59 +03:00
1 parent fdf744e578
commit e69a4bb7df
20 files changed
+3224 -170

No files matched your search

+467
View File
@@ -0,0 +1,467 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy"
def checking_socket_setup(outcome="success", close_delay=0.0):
if outcome == "success":
outcome_line = " return Connection()\n"
elif outcome == "refused":
outcome_line = " raise OSError('connection refused')\n"
else:
raise ValueError("unknown socket outcome: {}".format(outcome))
return (
"import json, os, socket, time\n"
"expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n"
"class Connection:\n"
" def close(self):\n"
" time.sleep("
+ repr(close_delay)
+ ")\n"
"def checked_create_connection(endpoint, timeout):\n"
" actual_connection = [endpoint[0], endpoint[1], timeout]\n"
" if actual_connection != expected_connection:\n"
" raise AssertionError('socket call mismatch: expected %r, got %r' "
"% (expected_connection, actual_connection))\n"
+ outcome_line
+ "socket.create_connection = checked_create_connection"
)
SUCCESSFUL_SOCKET_SETUP = checking_socket_setup()
REFUSED_SOCKET_SETUP = checking_socket_setup("refused")
class XrayProxyCliTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.config_home = Path(self.temp_dir.name)
self.environment = os.environ.copy()
self.environment["XDG_CONFIG_HOME"] = str(self.config_home)
self.environment["PYTHONDONTWRITEBYTECODE"] = "1"
def tearDown(self):
self.temp_dir.cleanup()
def write_config(
self,
port,
proxy_type="external",
name="Test proxy",
address="127.0.0.1",
no_proxy_extra=".example.test",
):
config_dir = self.config_home / "proxy"
config_dir.mkdir(exist_ok=True)
(config_dir / "config").write_text(
"[proxy]\n"
"type = {}\n"
"name = {}\n"
"address = {}\n"
"port = {}\n"
"no_proxy_extra = {}\n".format(
proxy_type, name, address, port, no_proxy_extra
),
encoding="utf-8",
)
def write_raw_config(self, content):
config_dir = self.config_home / "proxy"
config_dir.mkdir(exist_ok=True)
path = config_dir / "config"
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content, encoding="utf-8")
def run_cli(self, *arguments, environment=None):
command_environment = self.environment.copy()
if environment:
command_environment.update(environment)
return subprocess.run(
[str(CLI)] + list(arguments),
text=True,
capture_output=True,
env=command_environment,
check=False,
)
def run_cli_with_socket_setup(
self, arguments, setup, environment=None, expected_connection=None
):
command_environment = self.environment.copy()
if environment:
command_environment.update(environment)
command_environment["TEST_CLI_ARGUMENTS"] = json.dumps(list(arguments))
if expected_connection is not None:
command_environment["TEST_EXPECTED_CONNECTION"] = json.dumps(
list(expected_connection)
)
code = (
"import json, os, runpy, sys\n"
+ setup
+ "\nsys.argv = [sys.argv[1]] + "
"json.loads(os.environ['TEST_CLI_ARGUMENTS'])\n"
"runpy.run_path(sys.argv[0], run_name='__main__')"
)
return subprocess.run(
[sys.executable, "-c", code, str(CLI)],
text=True,
capture_output=True,
env=command_environment,
check=False,
)
def test_socket_fixture_rejects_wrong_endpoint_and_timeout(self):
self.write_config(23456)
result = self.run_cli_with_socket_setup(
("status",),
SUCCESSFUL_SOCKET_SETUP,
expected_connection=("wrong.example", 1, 0.5),
)
self.assertNotEqual(0, result.returncode)
self.assertIn("AssertionError: socket call mismatch", result.stderr)
def test_no_subcommand_has_no_side_effect_and_reports_usage(self):
result = self.run_cli()
self.assertEqual(2, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("укажите явную подкоманду", result.stderr)
self.assertFalse((self.config_home / "proxy").exists())
def test_old_python_version_gets_clear_error_before_main_program(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.assertEqual("", result.stdout)
self.assertIn("требует Python 3.8", result.stderr)
self.assertIn("Python 3.7", result.stderr)
def test_broken_config_symlink_is_not_treated_as_missing_config(self):
config_dir = self.config_home / "proxy"
config_dir.mkdir()
(config_dir / "config").symlink_to(self.config_home / "missing-config")
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertIn("отсутствующий файл", result.stderr)
self.assertNotIn("только для type=xray", result.stderr)
def test_missing_config_keeps_legacy_external_hiddify_endpoint(self):
result = self.run_cli("status", environment={"PROXY_PREFLIGHT_TIMEOUT": "0.05"})
output = result.stdout + result.stderr
self.assertIn("Hiddify", output)
self.assertIn("hproxy.local:12334", output)
def test_status_reports_reachable_external_proxy(self):
self.write_config(23456)
result = self.run_cli_with_socket_setup(
("status",),
SUCCESSFUL_SOCKET_SETUP,
expected_connection=("127.0.0.1", 23456, 2.0),
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("Test proxy (external) доступен", result.stdout)
self.assertEqual("", result.stderr)
def test_env_prints_complete_proxy_environment_for_external_proxy(self):
self.write_config(
23456, no_proxy_extra=" .example.test , git.example.test "
)
result = self.run_cli_with_socket_setup(
("env",),
SUCCESSFUL_SOCKET_SETUP,
expected_connection=("127.0.0.1", 23456, 2.0),
)
proxy_url = "http://127.0.0.1:23456"
socks_url = "socks5://127.0.0.1:23456"
no_proxy = (
"127.0.0.1,localhost,::1,[::1],.example.test,git.example.test"
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual("", result.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,
],
result.stdout.splitlines(),
)
def test_env_brackets_ipv6_without_requiring_host_ipv6(self):
self.write_config(23456, address="2001:db8::1", no_proxy_extra="")
result = self.run_cli_with_socket_setup(
("env",),
SUCCESSFUL_SOCKET_SETUP,
expected_connection=("2001:db8::1", 23456, 2.0),
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("HTTP_PROXY=http://[2001:db8::1]:23456\n", result.stdout)
self.assertIn("ALL_PROXY=socks5://[2001:db8::1]:23456\n", result.stdout)
def test_preflight_timeout_bounds_delayed_dns_resolution(self):
self.write_config(12334, address="slow-dns.example")
setup = (
"import socket, time; "
"socket.getaddrinfo = lambda *args, **kwargs: time.sleep(5)"
)
started = time.monotonic()
result = self.run_cli_with_socket_setup(
("status",), setup, {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}
)
elapsed = time.monotonic() - started
self.assertEqual(1, result.returncode)
self.assertLess(elapsed, 1.0)
self.assertIn("таймаут", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_preflight_timer_is_cancelled_before_closing_successful_connection(self):
self.write_config(23456)
setup = checking_socket_setup(close_delay=0.1)
result = self.run_cli_with_socket_setup(
("status",),
setup,
{"PROXY_PREFLIGHT_TIMEOUT": "0.01"},
expected_connection=("127.0.0.1", 23456, 0.01),
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("доступен", result.stdout)
self.assertNotIn("Traceback", result.stderr)
def test_unreachable_env_prints_no_partial_output(self):
self.write_config(23456)
result = self.run_cli_with_socket_setup(
("env",),
REFUSED_SOCKET_SETUP,
{"PROXY_PREFLIGHT_TIMEOUT": "0.1"},
expected_connection=("127.0.0.1", 23456, 0.1),
)
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("недоступен", result.stderr)
def test_unreachable_external_proxy_never_runs_child(self):
self.write_config(23456)
marker = self.config_home / "external-child-ran"
helper = self.config_home / "external-child"
helper.write_text(
"#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii"
)
helper.chmod(0o755)
result = self.run_cli_with_socket_setup(
("run", "--", str(helper), str(marker)),
REFUSED_SOCKET_SETUP,
{"PROXY_PREFLIGHT_TIMEOUT": "0.1"},
expected_connection=("127.0.0.1", 23456, 0.1),
)
self.assertNotEqual(0, result.returncode)
self.assertIn("недоступен", result.stderr)
self.assertFalse(marker.exists())
def test_xray_type_fails_closed_for_all_ticket_commands(self):
self.write_config(12334, proxy_type="xray")
marker = self.config_home / "child-ran"
helper = self.config_home / "child"
helper.write_text(
"#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii"
)
helper.chmod(0o755)
commands = [
("status",),
("env",),
("run", "--", str(helper), str(marker)),
("start",),
]
for command in commands:
with self.subTest(command=command):
result = self.run_cli(*command)
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("Ошибка:", result.stderr)
self.assertFalse(marker.exists())
def test_external_start_fails_without_starting_anything(self):
self.write_config(12334)
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("только для type=xray", result.stderr)
def test_run_preserves_arguments_exit_code_and_child_only_environment(self):
self.write_config(23456)
code = (
"import json, os, sys; "
"print(json.dumps({'args': sys.argv[1:], "
"'http': os.environ['HTTP_PROXY'], "
"'lower': os.environ['http_proxy']})); sys.exit(42)"
)
original_http = os.environ.get("HTTP_PROXY")
result = self.run_cli_with_socket_setup(
("run", "--", sys.executable, "-c", code, "a b", "--", "--flag"),
SUCCESSFUL_SOCKET_SETUP,
expected_connection=("127.0.0.1", 23456, 2.0),
)
self.assertEqual(42, result.returncode, result.stderr)
child = json.loads(result.stdout)
self.assertEqual(["a b", "--", "--flag"], child["args"])
expected_url = "http://127.0.0.1:23456"
self.assertEqual(expected_url, child["http"])
self.assertEqual(expected_url, child["lower"])
self.assertEqual(original_http, os.environ.get("HTTP_PROXY"))
def test_missing_run_command_returns_127(self):
self.write_config(12334)
result = self.run_cli("run", "--", "definitely-no-such-command-xyz")
self.assertEqual(127, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("не найдена в PATH", result.stderr)
def test_run_maps_child_signal_to_shell_exit_code(self):
self.write_config(23456)
result = self.run_cli_with_socket_setup(
(
"run",
"--",
sys.executable,
"-c",
"import os, signal; os.kill(os.getpid(), signal.SIGTERM)",
),
SUCCESSFUL_SOCKET_SETUP,
expected_connection=("127.0.0.1", 23456, 2.0),
)
self.assertEqual(143, result.returncode, result.stderr)
def test_run_requires_explicit_separator_and_command(self):
self.write_config(12334)
for command in (("run",), ("run", "true"), ("run", "--")):
with self.subTest(command=command):
result = self.run_cli(*command)
self.assertNotEqual(0, result.returncode)
self.assertIn("run -- COMMAND", result.stderr)
def test_invalid_preflight_timeout_is_rejected_by_status_env_and_run(self):
self.write_config(12334)
for value in ("", "zero", "0", "-1", "nan", "inf"):
for command in (("status",), ("env",), ("run", "--", "true")):
with self.subTest(value=value, command=command):
result = self.run_cli(
*command, environment={"PROXY_PREFLIGHT_TIMEOUT": value}
)
self.assertNotEqual(0, result.returncode)
self.assertIn("положительным числом", result.stderr)
if command == ("env",):
self.assertEqual("", result.stdout)
def test_extreme_positive_timeouts_fail_without_traceback(self):
self.write_config(12334)
for value in ("1e-7", "1e308"):
with self.subTest(value=value):
result = self.run_cli(
"status", environment={"PROXY_PREFLIGHT_TIMEOUT": value}
)
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("Ошибка:", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_ini_rejects_structure_key_and_value_grammar_errors(self):
valid = (
"[proxy]\n"
"type = external\n"
"name = Test\n"
"address = 127.0.0.1\n"
"port = 12334\n"
"no_proxy_extra =\n"
)
invalid_configs = {
"unknown section": valid + "[other]\nkey = value\n",
"default section": "[DEFAULT]\nfoo = bar\n" + valid,
"unknown key": valid + "extra = value\n",
"wrong key case": valid.replace("type = external", "Type = external"),
"duplicate key": valid + "port = 12335\n",
"missing key": valid.replace("name = Test\n", ""),
"multiline value": valid.replace("name = Test", "name = Test\n continued"),
"nul value": valid.replace("name = Test", "name = Te\0st").encode("utf-8"),
"empty name": valid.replace("name = Test", "name = "),
"empty address": valid.replace("address = 127.0.0.1", "address = "),
"port zero": valid.replace("port = 12334", "port = 0"),
"port high": valid.replace("port = 12334", "port = 65536"),
"port syntax": valid.replace("port = 12334", "port = +80"),
"empty no-proxy item": valid.replace(
"no_proxy_extra =", "no_proxy_extra = one.example,,two.example"
),
}
for label, content in invalid_configs.items():
with self.subTest(label=label):
self.write_raw_config(content)
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("Ошибка:", result.stderr)
def test_address_accepts_only_ip_literals_or_ascii_dns_names(self):
valid_addresses = ("127.0.0.1", "2001:db8::1", "proxy-1.example")
for address in valid_addresses:
with self.subTest(address=address):
self.write_config(12334, address=address)
result = self.run_cli("start")
self.assertIn("только для type=xray", result.stderr)
invalid_addresses = (
"http://proxy.example",
"proxy.example:12334",
"[::1]",
"fe80::1%eth0",
"has space",
"proxy/path",
"user@proxy",
".leading.example",
"trailing.example.",
"bad..example",
"-bad.example",
"bad-.example",
"прокси.example",
)
for address in invalid_addresses:
with self.subTest(address=address):
self.write_config(12334, address=address)
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertNotIn("только для type=xray", result.stderr)
self.assertIn("address", result.stderr)
if __name__ == "__main__":
unittest.main()