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

+4
View File
@@ -38,6 +38,7 @@ setup_fixture() {
"$REPO_ROOT/codex" \
"$REPO_ROOT/gemini" \
"$REPO_ROOT/claude" \
"$REPO_ROOT/xray-proxy" \
"$REPO_ROOT/scripts" \
"$repo_root/"
@@ -96,6 +97,9 @@ test_fresh_install_uses_home_target() {
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME"
[ -x "$home_dir/.local/bin/xray-proxy" ] || fail "Expected executable xray-proxy CLI in HOME"
[ -f "$home_dir/.config/xray-proxy/shell.bash" ] || fail "Expected xray-proxy shell module"
[ ! -e "$home_dir/README.md" ] || fail "Package README must not be stowed into HOME"
[ -L "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills symlink"
assert_file_contains "$home_dir/.bashrc" "[ -f ~/.bash_custom ] && source ~/.bash_custom"
assert_not_exists "$fixture/.bash_custom"
+184 -79
View File
@@ -3,6 +3,8 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FUNCTIONS_FILE="$REPO_ROOT/bash/.bash_functions"
CUSTOM_FILE="$REPO_ROOT/bash/.bash_custom"
SHELL_MODULE="$REPO_ROOT/xray-proxy/.config/xray-proxy/shell.bash"
pass_count=0
@@ -17,101 +19,204 @@ ok() {
}
assert_eq() {
local expected="$1" actual="$2" msg="$3"
[ "$expected" = "$actual" ] || fail "$msg (ожидалось '$expected', получено '$actual')"
local expected="$1" actual="$2" message="$3"
[ "$expected" = "$actual" ] || fail "$message (ожидалось '$expected', получено '$actual')"
}
# Изолированный fake-CLI на PATH: печатает env и аргументы, отдаёт заданный код.
make_fake_bin() {
local dir
dir="$(mktemp -d /tmp/proxy-launchers-test.XXXXXX)"
cat >"$dir/fakecli" <<'EOF'
#!/usr/bin/env bash
echo "HTTP_PROXY=${HTTP_PROXY:-}"
echo "NO_PROXY=${NO_PROXY:-}"
for a in "$@"; do echo "ARG=$a"; done
exit "${FAKE_EXIT:-0}"
valid_env() {
cat <<'EOF'
HTTP_PROXY=http://proxy.test:1234
HTTPS_PROXY=http://proxy.test:1234
ALL_PROXY=socks5://proxy.test:1234
NO_PROXY=proxy.test,localhost,127.0.0.1,::1,[::1],.example.test
http_proxy=http://proxy.test:1234
https_proxy=http://proxy.test:1234
all_proxy=socks5://proxy.test:1234
no_proxy=proxy.test,localhost,127.0.0.1,::1,[::1],.example.test
EOF
chmod +x "$dir/fakecli"
echo "$dir"
}
# --- Каждый кейс в отдельном subshell, чтобы env/функции не протекали ---
assert_five_proxy_commands() {
local command_name
for command_name in proxy_on proxy_off pclaude pcodex myip; do
declare -F "$command_name" >/dev/null || fail "нет функции $command_name"
done
}
# 1. Прокси недоступен → бинарник НЕ вызывается, ненулевой код.
# Самостоятельный модуль предоставляет весь публичный Bash-интерфейс.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 1; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
out="$(_proxy_run fakecli 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail "недоступный прокси: ожидался ненулевой код"
case "$out" in *ARG=*) fail "недоступный прокси: бинарник не должен запускаться";; esac
case "$out" in *"недоступен"*) ;; *) fail "недоступный прокси: нет понятного сообщения";; esac
) && ok "недоступный прокси → fail-fast без запуска бинарника"
source "$SHELL_MODULE"
assert_five_proxy_commands
) && ok "самостоятельное подключение предоставляет пять proxy-команд"
# 2. proxy-env виден дочернему процессу и НЕ протекает в родительский shell.
# Обычная .bash_custom независимо подключает общие функции и proxy-модуль.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
unset HTTP_PROXY NO_PROXY || true
out="$(_proxy_run fakecli)"
case "$out" in *"HTTP_PROXY=http://hproxy.local:12334"*) ;; *) fail "child: HTTP_PROXY не выставлен";; esac
case "$out" in *"NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru"*) ;; *) fail "child: NO_PROXY неверен";; esac
[ -z "${HTTP_PROXY:-}" ] || fail "leak: HTTP_PROXY протёк в родительский shell"
[ -z "${NO_PROXY:-}" ] || fail "leak: NO_PROXY протёк в родительский shell"
) && ok "proxy-env в дочернем процессе, без протечки в shell"
fixture="$(mktemp -d /tmp/proxy-bash-custom-test.XXXXXX)"
mkdir -p "$fixture/.config/xray-proxy"
ln -s "$CUSTOM_FILE" "$fixture/.bash_custom"
ln -s "$FUNCTIONS_FILE" "$fixture/.bash_functions"
ln -s "$SHELL_MODULE" "$fixture/.config/xray-proxy/shell.bash"
output="$(
HOME="$fixture" bash --noprofile --norc -ic '
source "$HOME/.bash_custom"
for name in proxy_on proxy_off pclaude pcodex myip update_hproxy; do
declare -F "$name" >/dev/null || exit 1
done
printf ready
' 2>/dev/null
)"
assert_eq ready "$output" ".bash_custom не подключил оба независимых модуля"
) && ok "обычное подключение предоставляет proxy-команды и update_hproxy"
# 3. Проброс кода возврата дочернего процесса.
# proxy_on атомарно применяет ровно восемь переменных и не запрашивает IP.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
FAKE_EXIT=42 _proxy_run fakecli >/dev/null; rc=$?
assert_eq 42 "$rc" "код возврата дочернего процесса не проброшен"
) && ok "проброс кода возврата дочернего процесса"
# 4. Аргументы с пробелами и `--` доходят без искажений.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
out="$(_proxy_run fakecli "a b" -- --flag)"
case "$out" in *$'ARG=a b\n'*) ;; *) fail "arg с пробелом потерян";; esac
case "$out" in *"ARG=--"*) ;; *) fail "разделитель -- потерян";; esac
case "$out" in *"ARG=--flag"*) ;; *) fail "флаг после -- потерян";; esac
) && ok "аргументы с пробелами и -- проброшены без искажений"
# 5. Команда не в PATH → отдельное сообщение, код 127.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
out="$(_proxy_run definitely-no-such-cli-xyz 2>&1)"; rc=$?
assert_eq 127 "$rc" "отсутствующая команда: ожидался код 127"
case "$out" in *"не найдена"*) ;; *) fail "отсутствующая команда: нет понятного сообщения";; esac
) && ok "команда не в PATH → код 127 с сообщением"
# 6. Регресс proxy_on: точный контракт экспортируемых переменных.
(
source "$FUNCTIONS_FILE"
curl() { echo "1.2.3.4"; } # без сети
source "$SHELL_MODULE"
_proxy_cli() {
[ "$1" = env ] || return 99
valid_env
}
curl() { fail "proxy_on не должен вызывать curl"; }
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \
http_proxy https_proxy all_proxy no_proxy || true
proxy_on >/dev/null
np="hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru"
assert_eq "http://hproxy.local:12334" "${HTTP_PROXY}" "proxy_on HTTP_PROXY"
assert_eq "http://hproxy.local:12334" "${HTTPS_PROXY}" "proxy_on HTTPS_PROXY"
assert_eq "socks5://hproxy.local:12334" "${ALL_PROXY}" "proxy_on ALL_PROXY"
assert_eq "$np" "${NO_PROXY}" "proxy_on NO_PROXY"
assert_eq "${HTTP_PROXY}" "${http_proxy}" "proxy_on http_proxy дубль"
assert_eq "${HTTPS_PROXY}" "${https_proxy}" "proxy_on https_proxy дубль"
assert_eq "${ALL_PROXY}" "${all_proxy}" "proxy_on all_proxy дубль"
assert_eq "$np" "${no_proxy}" "proxy_on no_proxy дубль"
) && ok "регресс proxy_on: контракт переменных не изменился"
assert_eq "http://proxy.test:1234" "$HTTP_PROXY" "HTTP_PROXY"
assert_eq "$HTTP_PROXY" "$http_proxy" "http_proxy"
assert_eq "http://proxy.test:1234" "$HTTPS_PROXY" "HTTPS_PROXY"
assert_eq "$HTTPS_PROXY" "$https_proxy" "https_proxy"
assert_eq "socks5://proxy.test:1234" "$ALL_PROXY" "ALL_PROXY"
assert_eq "$ALL_PROXY" "$all_proxy" "all_proxy"
assert_eq "proxy.test,localhost,127.0.0.1,::1,[::1],.example.test" "$NO_PROXY" "NO_PROXY"
assert_eq "$NO_PROXY" "$no_proxy" "no_proxy"
) && ok "proxy_on применяет полный восьмиключевой вывод без запроса IP"
# Любой неверный машинный вывод оставляет прежнее окружение целиком.
missing_output="$(valid_env | sed '/^no_proxy=/d')"
unknown_output="$(valid_env; printf 'EXTRA_PROXY=bad\n')"
duplicate_output="$(valid_env; printf 'HTTP_PROXY=duplicate\n')"
for bad_output in \
"$missing_output" \
"$unknown_output" \
"$duplicate_output" \
"BROKEN-LINE"; do
(
source "$SHELL_MODULE"
export HTTP_PROXY=old-http HTTPS_PROXY=old-https ALL_PROXY=old-all NO_PROXY=old-no
export http_proxy=old-http-lower https_proxy=old-https-lower
export all_proxy=old-all-lower no_proxy=old-no-lower
_proxy_cli() { printf '%s\n' "$bad_output"; }
if proxy_on >/dev/null 2>&1; then
fail "proxy_on принял неверный вывод"
fi
assert_eq old-http "$HTTP_PROXY" "HTTP_PROXY изменился при ошибке"
assert_eq old-https "$HTTPS_PROXY" "HTTPS_PROXY изменился при ошибке"
assert_eq old-all "$ALL_PROXY" "ALL_PROXY изменился при ошибке"
assert_eq old-no "$NO_PROXY" "NO_PROXY изменился при ошибке"
assert_eq old-http-lower "$http_proxy" "http_proxy изменился при ошибке"
assert_eq old-https-lower "$https_proxy" "https_proxy изменился при ошибке"
assert_eq old-all-lower "$all_proxy" "all_proxy изменился при ошибке"
assert_eq old-no-lower "$no_proxy" "no_proxy изменился при ошибке"
) || fail "атомарность нарушена для неверного вывода"
done
ok "неверный env-вывод отклоняется без частичного изменения окружения"
# Даже полный stdout не применяется, если CLI завершился с ошибкой.
(
source "$SHELL_MODULE"
export HTTP_PROXY=old HTTPS_PROXY=old ALL_PROXY=old NO_PROXY=old
export http_proxy=old https_proxy=old all_proxy=old no_proxy=old
_proxy_cli() {
valid_env
return 1
}
if proxy_on >/dev/null 2>&1; then
fail "proxy_on принял вывод неуспешного CLI"
fi
for key in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \
http_proxy https_proxy all_proxy no_proxy; do
assert_eq old "${!key}" "$key изменился после ошибки CLI"
done
) && ok "stdout неуспешного env не изменяет окружение"
# CRLF и NUL считаются повреждённым машинным выводом и не могут быть молча
# нормализованы перед атомарной проверкой.
for malformed_kind in crlf nul; do
(
source "$SHELL_MODULE"
export HTTP_PROXY=old HTTPS_PROXY=old ALL_PROXY=old NO_PROXY=old
export http_proxy=old https_proxy=old all_proxy=old no_proxy=old
_proxy_cli() {
case "$malformed_kind" in
crlf)
while IFS= read -r line; do
printf '%s\r\n' "$line"
done < <(valid_env)
;;
nul)
printf 'HTTP_PROXY=http://proxy.test:12\00034\n'
valid_env | sed '/^HTTP_PROXY=/d'
;;
esac
}
if proxy_on >/dev/null 2>&1; then
fail "proxy_on принял $malformed_kind в env-выводе"
fi
for key in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \
http_proxy https_proxy all_proxy no_proxy; do
assert_eq old "${!key}" "$key изменился после $malformed_kind"
done
) || fail "атомарность нарушена для $malformed_kind"
done
ok "CRLF и NUL отклоняются без изменения окружения"
# Обёртки передают CLI команду, аргументы и код возврата без искажений.
(
source "$SHELL_MODULE"
_proxy_cli() {
printf '<%s>\n' "$@"
return 42
}
set +e
output="$(pclaude "a b" -- --flag)"
rc=$?
set -e
assert_eq 42 "$rc" "pclaude не сохранил код возврата"
assert_eq $'<run>\n<-->\n<claude>\n<a b>\n<-->\n<--flag>' "$output" "pclaude исказил аргументы"
set +e
output="$(pcodex --search)"
rc=$?
set -e
assert_eq 42 "$rc" "pcodex не сохранил код возврата"
assert_eq $'<run>\n<-->\n<codex>\n<--search>' "$output" "pcodex исказил аргументы"
) && ok "pclaude и pcodex делегируют run и сохраняют аргументы/код"
# proxy_off очищает оба регистра и не смешивает действие с диагностикой.
(
source "$SHELL_MODULE"
export HTTP_PROXY=x HTTPS_PROXY=x ALL_PROXY=x NO_PROXY=x
export http_proxy=x https_proxy=x all_proxy=x no_proxy=x
curl() { fail "proxy_off не должен вызывать curl"; }
proxy_off >/dev/null
for key in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \
http_proxy https_proxy all_proxy no_proxy; do
[ -z "${!key+x}" ] || fail "$key не очищен"
done
) && ok "proxy_off очищает восемь переменных без запроса IP"
# Общий файл больше не владеет proxy-интерфейсом, но сохраняет update_hproxy.
(
source "$FUNCTIONS_FILE"
declare -F update_hproxy >/dev/null || fail "update_hproxy потерян"
for name in proxy_on proxy_off pclaude pcodex myip _proxy_run _proxy_cli; do
if declare -F "$name" >/dev/null; then
fail "$name всё ещё определён в bash/.bash_functions"
fi
done
) && ok "владение proxy-функциями перенесено, update_hproxy сохранён"
# 7. shellcheck (если доступен).
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -s bash "$FUNCTIONS_FILE" || fail "shellcheck нашёл проблемы в bash/.bash_functions"
shellcheck -s bash "$FUNCTIONS_FILE" "$CUSTOM_FILE" "$SHELL_MODULE" "$0" \
|| fail "shellcheck нашёл проблемы"
ok "shellcheck чист"
else
echo "skip - shellcheck не установлен"
+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()
+890
View File
@@ -0,0 +1,890 @@
#!/usr/bin/env python3
import json
import os
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from urllib.parse import quote
REPO_ROOT = Path(__file__).resolve().parents[1]
CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy"
UUID = "11111111-2222-4333-8444-555555555555"
PBK = "PUBLIC_KEY_SENTINEL_7f8a"
SID = "SHORT_ID_SENTINEL_9b3c"
PATH_SECRET = "/tenant/PATH_SENTINEL_a1b2"
PCS_A = "a" * 64
PCS_B = "b" * 64
FAKE_XRAY = r'''#!/usr/bin/env python3
import json
import os
import re
import signal
import socket
import sys
import threading
log_path = os.environ["FAKE_XRAY_LOG"]
def record(kind, **values):
values["kind"] = kind
values["argv"] = sys.argv[1:]
values["executable"] = os.path.realpath(sys.argv[0])
with open(log_path, "a", encoding="utf-8") as stream:
stream.write(json.dumps(values, sort_keys=True) + "\n")
if sys.argv[1:] == ["version"]:
record("version")
print(os.environ["FAKE_XRAY_VERSION"])
raise SystemExit(int(os.environ.get("FAKE_VERSION_EXIT", "0")))
is_test = len(sys.argv) == 5 and sys.argv[1:4] == ["run", "-test", "-c"]
is_run = len(sys.argv) == 4 and sys.argv[1:3] == ["run", "-c"]
if not (is_test or is_run):
record("bad-argv")
raise SystemExit(64)
config_path = sys.argv[-1]
def reject_constant(value):
raise ValueError("non-JSON constant: " + value)
with open(config_path, encoding="utf-8") as stream:
config = json.load(stream, parse_constant=reject_constant)
if os.environ["FAKE_XRAY_VERSION"].startswith("Xray 26.3.27"):
tls = config.get("outbounds", [{}])[0].get("streamSettings", {}).get("tlsSettings", {})
for field in ("pinnedPeerCertSha256", "verifyPeerCertByName"):
if field in tls and not isinstance(tls[field], str):
raise SystemExit(65)
if "pinnedPeerCertSha256" in tls:
hashes = tls["pinnedPeerCertSha256"].split(",")
if not hashes or any(re.fullmatch(r"[0-9a-fA-F]{64}", value) is None for value in hashes):
raise SystemExit(65)
if "verifyPeerCertByName" in tls and any(
not value for value in tls["verifyPeerCertByName"].split(",")
):
raise SystemExit(65)
if sys.argv[2] == "-test":
record("test", config=config)
if os.environ.get("FAKE_TEST_FAIL") == "1":
sys.stderr.write(os.environ.get("FAKE_TEST_SECRET", "test failed"))
raise SystemExit(1)
raise SystemExit(0)
record("run", config=config)
inbound = config["inbounds"][0]
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listen_port = int(os.environ.get("FAKE_XRAY_LISTEN_PORT", inbound["port"]))
server.bind((inbound["listen"], listen_port))
server.listen(16)
server.settimeout(0.1)
stopping = False
def stop(signum, _frame):
global stopping
record("signal", signum=signum)
stopping = True
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGHUP, stop)
def serve(client):
try:
first = client.recv(1)
if first == b"\x05":
methods = client.recv(2)
if len(methods) == 2:
client.recv(methods[1])
client.sendall(b"\x05\x00")
request = client.recv(4)
if len(request) != 4:
return
address_type = request[3]
if address_type == 1:
client.recv(4)
elif address_type == 3:
size = client.recv(1)[0]
client.recv(size)
elif address_type == 4:
client.recv(16)
client.recv(2)
client.sendall(b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x00")
if client.recv(4) == b"PING":
client.sendall(b"PONG")
return
request = first + client.recv(8192)
if b"ifconfig.me" in request:
record("ip-probe", request=request.decode("latin1"))
if os.environ.get("FAKE_IP_FAIL") == "1":
return
body = b"203.0.113.77\n"
else:
record("http-request", request=request.decode("latin1"))
body = b"HTTP-OK"
client.sendall(
b"HTTP/1.1 200 OK\r\nContent-Length: "
+ str(len(body)).encode("ascii")
+ b"\r\nConnection: close\r\n\r\n"
+ body
)
finally:
client.close()
while not stopping:
try:
client, _address = server.accept()
except socket.timeout:
continue
threading.Thread(target=serve, args=(client,), daemon=True).start()
server.close()
'''
class ManagedXrayCliTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.home = self.root / "home"
self.config_home = self.root / "config"
self.state_home = self.root / "state"
self.bin_dir = self.root / "bin"
self.home.mkdir()
self.bin_dir.mkdir()
self.log = self.root / "xray.log"
self.xray = self.bin_dir / "xray"
self.xray.write_text(FAKE_XRAY, encoding="utf-8")
self.xray.chmod(0o755)
self.environment = os.environ.copy()
self.environment.update(
{
"HOME": str(self.home),
"XDG_CONFIG_HOME": str(self.config_home),
"XDG_STATE_HOME": str(self.state_home),
"PATH": str(self.bin_dir) + os.pathsep + self.environment["PATH"],
"PYTHONDONTWRITEBYTECODE": "1",
"FAKE_XRAY_LOG": str(self.log),
"FAKE_XRAY_VERSION": "Xray 26.3.27 (fake)",
"PROXY_PREFLIGHT_TIMEOUT": "0.5",
"XRAY_PROXY_STOP_TIMEOUT": "2",
}
)
self.port = self.free_port()
self.write_machine_config("xray")
def tearDown(self):
self.temporary.cleanup()
@staticmethod
def free_port():
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return listener.getsockname()[1]
def write_machine_config(self, proxy_type):
directory = self.config_home / "proxy"
directory.mkdir(parents=True, exist_ok=True)
(directory / "config").write_text(
"[proxy]\n"
"type = {}\n"
"name = Managed test\n"
"address = 127.0.0.1\n"
"port = {}\n"
"no_proxy_extra = .example.test\n".format(proxy_type, self.port),
encoding="utf-8",
)
def write_profile(self, url, explicit=False):
if explicit:
path = self.root / "chosen.url"
else:
path = self.config_home / "xray-proxy/default.url"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(url + "\n", encoding="utf-8")
path.chmod(0o600)
return path
def tcp_profile(self, extra=""):
return (
"vless://{}@edge.example:443?type=tcp&security=reality&"
"encryption=none&flow=xtls-rprx-vision&sni=front.example&fp=chrome&"
"pbk={}&sid={}&headerType=none&host=ignored.example&alpn=h2&hiddify=1{}"
"#Synthetic".format(UUID, PBK, SID, extra)
)
def xhttp_profile(self, extra="", modern=True):
opaque = quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe="")
modern_fields = (
"&pcs={}%2C{}&vcn=name-a.example%2Cname-b.example".format(
PCS_A, PCS_B
)
if modern
else ""
)
return (
"vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none&"
"sni=tls.example&fp=firefox&alpn=http%2F1.1%2Ch2&host=host.example&"
"path={}&mode=auto&extra={}{}&hiddify=1&core=xray&headerType=none{}#Hiddify"
).format(UUID, quote(PATH_SECRET, safe=""), opaque, modern_fields, extra)
def run_cli(self, *args, environment=None, timeout=8):
env = self.environment.copy()
if environment:
env.update(environment)
return subprocess.run(
[str(CLI)] + list(args),
text=True,
capture_output=True,
env=env,
timeout=timeout,
check=False,
)
def wait_for_state(self, process):
state = self.state_home / "xray-proxy/state.json"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if state.exists():
status = self.run_cli("status")
if status.returncode == 0:
return state
if process.poll() is not None:
stdout, stderr = process.communicate()
self.fail("start exited early: {} {}".format(stdout, stderr))
time.sleep(0.03)
self.fail("managed state did not become ready")
def log_entries(self):
if not self.log.exists():
return []
return [json.loads(line) for line in self.log.read_text().splitlines()]
def start(self, profile=None, version="26.3.27"):
self.environment["FAKE_XRAY_VERSION"] = "Xray {} (fake)".format(version)
args = [str(CLI), "start"]
if profile is not None:
args.extend(["-f", str(profile)])
process = subprocess.Popen(
args,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=self.environment,
)
self.wait_for_state(process)
return process
def stop_and_collect(self, process):
self.write_machine_config("external")
stopped = self.run_cli("stop")
stdout, stderr = process.communicate(timeout=4)
self.assertEqual(0, stopped.returncode, stopped.stderr)
self.assertEqual(0, process.returncode, stderr)
return stdout, stderr
def assert_no_secrets(self, text, profile):
for secret in (profile, UUID, PBK, SID, PATH_SECRET):
self.assertNotIn(secret, text)
def test_complete_managed_lifecycle_for_both_versions_and_profile_locations(self):
profiles = (("26.3.27", self.xhttp_profile(), False), ("25.12.8", self.tcp_profile(), True))
for version, profile_text, explicit in profiles:
with self.subTest(version=version):
profile = self.write_profile(profile_text, explicit=explicit)
process = self.start(profile if explicit else None, version)
status = self.run_cli("status")
env = self.run_cli("env")
child = self.run_cli(
"run",
"--",
sys.executable,
"-c",
"import os; print(os.environ['HTTP_PROXY'])",
)
self.assertEqual(0, status.returncode, status.stderr)
self.assertIn("управляемый Xray", status.stdout)
self.assertEqual(0, env.returncode, env.stderr)
self.assertIn("HTTP_PROXY=http://127.0.0.1:{}".format(self.port), env.stdout)
self.assertEqual(0, child.returncode, child.stderr)
self.assertIn(str(self.xray.resolve()), process.stdout.readline())
with socket.create_connection(("127.0.0.1", self.port), timeout=1) as http:
http.sendall(b"GET http://example.test/ HTTP/1.1\r\nHost: example.test\r\n\r\n")
self.assertIn(b"HTTP-OK", http.recv(4096))
with socket.create_connection(("127.0.0.1", self.port), timeout=1) as socks:
socks.sendall(b"\x05\x01\x00")
self.assertEqual(b"\x05\x00", socks.recv(2))
socks.sendall(b"\x05\x01\x00\x03\x0cexample.test\x00\x50")
self.assertEqual(10, len(socks.recv(10)))
socks.sendall(b"PING")
self.assertEqual(b"PONG", socks.recv(4))
stdout, stderr = self.stop_and_collect(process)
entries = self.log_entries()
kinds = [entry["kind"] for entry in entries]
self.assertLess(kinds.index("version"), kinds.index("test"))
self.assertLess(kinds.index("test"), kinds.index("run"))
self.assertIn("ip-probe", kinds)
for entry in entries:
self.assertEqual(str(self.xray.resolve()), entry["executable"])
self.assertFalse((self.state_home / "xray-proxy/state.json").exists())
self.assertFalse((self.state_home / "xray-proxy/client.json").exists())
self.assert_no_secrets(stdout + stderr + status.stdout + env.stdout, profile_text)
self.log.unlink()
self.port = self.free_port()
self.write_machine_config("xray")
def test_generated_xhttp_config_preserves_fields_and_redacts_path(self):
profile_text = self.xhttp_profile()
self.write_profile(profile_text)
process = self.start()
stdout, stderr = self.stop_and_collect(process)
config = next(entry["config"] for entry in self.log_entries() if entry["kind"] == "test")
outbound = config["outbounds"][0]
stream = outbound["streamSettings"]
self.assertEqual("xhttp", stream["network"])
self.assertEqual(
{"host": "host.example", "path": PATH_SECRET, "mode": "auto", "extra": {"downloadSettings": {"address": "127.0.0.1"}}},
stream["xhttpSettings"],
)
self.assertEqual(
PCS_A + "," + PCS_B,
stream["tlsSettings"]["pinnedPeerCertSha256"],
)
self.assertEqual(
"name-a.example,name-b.example",
stream["tlsSettings"]["verifyPeerCertByName"],
)
self.assertEqual(["http/1.1", "h2"], stream["tlsSettings"]["alpn"])
self.assertEqual("none", config["log"]["access"])
self.assertIn("длина пути: {}".format(len(PATH_SECRET)), stdout)
self.assert_no_secrets(stdout + stderr, profile_text)
def test_reality_mapping_state_shape_modes_and_child_argv_are_closed(self):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
process = self.start()
state_path = self.state_home / "xray-proxy/state.json"
config_path = self.state_home / "xray-proxy/client.json"
state = json.loads(state_path.read_text(encoding="utf-8"))
config = json.loads(config_path.read_text(encoding="utf-8"))
self.assertEqual(0o600, state_path.stat().st_mode & 0o777)
self.assertEqual(0o600, config_path.stat().st_mode & 0o777)
self.assertEqual(0o700, state_path.parent.stat().st_mode & 0o777)
self.assertEqual(str(self.xray.resolve()), state["xray_path"])
self.assertEqual("127.0.0.1", state["address"])
self.assertEqual(self.port, state["port"])
self.assertEqual(64, len(state["profile_fingerprint"]))
state_text = state_path.read_text(encoding="utf-8")
self.assert_no_secrets(state_text, profile_text)
stream = config["outbounds"][0]["streamSettings"]
inbound = config["inbounds"][0]
self.assertEqual("127.0.0.1", inbound["listen"])
self.assertEqual(self.port, inbound["port"])
self.assertEqual("socks", inbound["protocol"])
self.assertEqual("raw", stream["network"])
self.assertEqual("reality", stream["security"])
self.assertEqual(
{
"serverName": "front.example",
"fingerprint": "chrome",
"password": PBK,
"shortId": SID,
},
stream["realitySettings"],
)
self.stop_and_collect(process)
for entry in self.log_entries():
argv_text = "\0".join(entry["argv"])
self.assert_no_secrets(argv_text, profile_text)
def test_old_version_preserves_explicit_legacy_insecure(self):
profile_text = self.xhttp_profile(
"&allowInsecure=true&insecure=true", modern=False
)
self.write_profile(profile_text)
process = self.start(version="25.12.8")
stdout, stderr = self.stop_and_collect(process)
config = next(entry["config"] for entry in self.log_entries() if entry["kind"] == "test")
outbound = config["outbounds"][0]
self.assertEqual("xhttp", outbound["streamSettings"]["network"])
self.assertTrue(outbound["streamSettings"]["tlsSettings"]["allowInsecure"])
self.assertIn("Предупреждение", stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self):
invalid_versions = (
"Xray 26.3.270",
"junk Xray 26.3.27",
"Xray 26.5.9",
"Xray",
"",
)
for version_line in invalid_versions:
with self.subTest(version=version_line):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
result = self.run_cli("start", environment={"FAKE_XRAY_VERSION": version_line})
self.assertNotEqual(0, result.returncode)
self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()])
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
self.log.unlink()
profile_text = self.tcp_profile()
self.write_profile(profile_text)
result = self.run_cli("start", environment={"FAKE_VERSION_EXIT": "1"})
self.assertNotEqual(0, result.returncode)
self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()])
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
self.log.unlink()
invalid_profiles = (
self.tcp_profile("&mystery=value"),
self.tcp_profile("&sni=duplicate.example"),
self.tcp_profile("&headerType=http"),
self.tcp_profile("&flow=other"),
self.tcp_profile("&sni=bad%0Aline"),
self.tcp_profile().replace("@edge.example:443?", "@edge.example:443/path?"),
self.tcp_profile().replace("type=tcp", "type=grpc"),
self.tcp_profile().replace("&pbk={}".format(PBK), ""),
self.xhttp_profile("&allowInsecure=True"),
self.xhttp_profile("&allowInsecure=true&insecure=false"),
self.xhttp_profile().replace("security=tls", "security=reality"),
)
for profile_text in invalid_profiles:
with self.subTest(profile=profile_text[-50:]):
self.write_profile(profile_text)
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()])
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
self.log.unlink()
def test_exact_boolean_version_alpn_and_extra_branches(self):
accepted_alpn = ("http/1.1", "h2", "h3", "http/1.1,h2", "h2,h3")
for value in accepted_alpn:
with self.subTest(alpn=value):
profile_text = self.xhttp_profile().replace(
"alpn=http%2F1.1%2Ch2", "alpn=" + quote(value, safe="")
)
self.write_profile(profile_text)
process = self.start()
self.stop_and_collect(process)
config = next(
entry["config"]
for entry in self.log_entries()
if entry["kind"] == "test"
)
self.assertEqual(
value.split(","),
config["outbounds"][0]["streamSettings"]["tlsSettings"]["alpn"],
)
self.log.unlink()
self.port = self.free_port()
self.write_machine_config("xray")
cases = (
("26.3.27", "", False, False),
("26.3.27", "&allowInsecure=false", False, False),
("26.3.27", "&allowInsecure=false&insecure=false", False, False),
("26.3.27", "&insecure=true", True, False),
("26.3.27", "&allowInsecure=true&insecure=true", True, False),
("25.12.8", "", False, False),
("25.12.8", "&insecure=false", False, False),
("25.12.8", "&allowInsecure=false&insecure=false", False, False),
("25.12.8", "&allowInsecure=true", True, True),
("25.12.8", "&allowInsecure=true&insecure=true", True, True),
)
for version, fields, warns, emitted in cases:
with self.subTest(version=version, fields=fields):
profile_text = self.xhttp_profile(fields, modern=version == "26.3.27")
self.write_profile(profile_text)
process = self.start(version=version)
stdout, stderr = self.stop_and_collect(process)
config = next(
entry["config"]
for entry in self.log_entries()
if entry["kind"] == "test"
)
tls = config["outbounds"][0]["streamSettings"]["tlsSettings"]
self.assertEqual(emitted, tls.get("allowInsecure", False))
self.assertEqual(warns, "Предупреждение" in stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
self.log.unlink()
self.port = self.free_port()
self.write_machine_config("xray")
rejected = (
self.xhttp_profile("&allowInsecure=TRUE"),
self.xhttp_profile("&allowInsecure=1"),
self.xhttp_profile("&allowInsecure=true&insecure=false"),
self.xhttp_profile().replace(
quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""),
quote('{"same":1,"same":2}', safe=""),
),
self.xhttp_profile().replace("alpn=http%2F1.1%2Ch2", "alpn=h4"),
self.xhttp_profile().replace(
quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""),
quote('{"threshold":NaN}', safe=""),
),
self.xhttp_profile().replace(
quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""),
quote('{"threshold":Infinity}', safe=""),
),
self.xhttp_profile().replace(
quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""),
quote('{"threshold":-Infinity}', safe=""),
),
)
self.environment["FAKE_XRAY_VERSION"] = "Xray 26.3.27 (fake)"
for profile_text in rejected:
with self.subTest(rejected=profile_text[-45:]):
self.write_profile(profile_text)
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()])
if "threshold" in profile_text:
self.assertIn("extra", result.stderr)
self.assertNotIn("run -test", result.stderr)
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
self.log.unlink()
old_with_modern = self.xhttp_profile()
self.write_profile(old_with_modern)
result = self.run_cli(
"start", environment={"FAKE_XRAY_VERSION": "Xray 25.12.8 (fake)"}
)
self.assertNotEqual(0, result.returncode)
self.assertIn("pcs/vcn", result.stderr)
self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()])
def test_fake_run_test_rejects_non_json_constants(self):
for constant in ("NaN", "Infinity", "-Infinity"):
with self.subTest(constant=constant):
config = self.root / "invalid-constant.json"
config.write_text('{"value":' + constant + '}\n', encoding="utf-8")
result = subprocess.run(
[str(self.xray), "run", "-test", "-c", str(config)],
text=True,
capture_output=True,
env=self.environment,
check=False,
)
self.assertNotEqual(0, result.returncode)
def test_failed_ip_diagnostic_is_proxy_observed_warning_and_non_fatal(self):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
process = self.start()
# The fake inherited the flag at launch only; restart with the failure flag.
self.write_machine_config("external")
self.run_cli("stop")
process.communicate(timeout=4)
self.log.unlink()
self.port = self.free_port()
self.write_machine_config("xray")
process = subprocess.Popen(
[str(CLI), "start"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=dict(self.environment, FAKE_IP_FAIL="1"),
)
self.wait_for_state(process)
self.assertIsNone(process.poll())
stdout, stderr = self.stop_and_collect(process)
self.assertIn("Предупреждение", stderr)
self.assertIn("ip-probe", [entry["kind"] for entry in self.log_entries()])
self.assert_no_secrets(stdout + stderr, profile_text)
def test_immediate_stop_after_first_ready_status_returns_foreground_start(self):
for attempt in range(3):
with self.subTest(attempt=attempt):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
process = self.start()
stdout, stderr = self.stop_and_collect(process)
self.assertFalse((self.state_home / "xray-proxy/state.json").exists())
self.assertFalse((self.state_home / "xray-proxy/client.json").exists())
self.assert_no_secrets(stdout + stderr, profile_text)
self.log.unlink()
self.port = self.free_port()
self.write_machine_config("xray")
def test_profile_permissions_follow_symlink_and_test_failure_does_not_leak(self):
profile_text = self.xhttp_profile()
target = self.root / "target.url"
target.write_text(profile_text + "\n", encoding="utf-8")
target.chmod(0o640)
link = self.root / "profile.url"
link.symlink_to(target)
result = self.run_cli("start", "-f", str(link))
self.assertNotEqual(0, result.returncode)
self.assertIn("chmod 600", result.stderr)
self.assertEqual([], self.log_entries())
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
target.chmod(0o600)
target.write_text(profile_text + "\n" + self.tcp_profile() + "\n", encoding="utf-8")
result = self.run_cli("start", "-f", str(link))
self.assertNotEqual(0, result.returncode)
self.assertIn("ровно одну", result.stderr)
self.assertEqual([], self.log_entries())
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
target.write_text(profile_text + "\n", encoding="utf-8")
target.chmod(0o600)
result = self.run_cli(
"start",
"-f",
str(link),
environment={"FAKE_TEST_FAIL": "1", "FAKE_TEST_SECRET": PATH_SECRET},
)
self.assertNotEqual(0, result.returncode)
self.assertEqual(["version", "test"], [entry["kind"] for entry in self.log_entries()])
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
self.assertFalse((self.state_home / "xray-proxy/client.json").exists())
def test_xray_start_requires_loopback_before_selecting_binary(self):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
config_path = self.config_home / "proxy/config"
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
"address = 127.0.0.1", "address = 192.0.2.10"
),
encoding="utf-8",
)
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertIn("loopback", result.stderr)
self.assertEqual([], self.log_entries())
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
def test_foreign_state_and_listener_fail_closed_and_are_not_signalled(self):
listener = socket.socket()
listener.bind(("127.0.0.1", self.port))
listener.listen(1)
state_dir = self.state_home / "xray-proxy"
state_dir.mkdir(parents=True)
state = {
"supervisor_pid": os.getpid(),
"xray_pid": os.getpid(),
"xray_path": str(self.xray.resolve()),
"port": self.port,
"address": "127.0.0.1",
"config_path": str(state_dir / "client.json"),
"profile_fingerprint": "0" * 64,
}
(state_dir / "state.json").write_text(json.dumps(state), encoding="utf-8")
(state_dir / "state.json").chmod(0o600)
marker = self.root / "child-ran"
for command in (("status",), ("env",), ("run", "--", "touch", str(marker)), ("stop",)):
with self.subTest(command=command):
result = self.run_cli(*command)
self.assertNotEqual(0, result.returncode)
self.assertIn("не подтвержден", result.stderr)
self.assertFalse(marker.exists())
listener.close()
def test_substituted_machine_port_fails_closed_without_touching_foreign_listener(self):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
process = self.start()
managed_port = self.port
foreign_port = self.free_port()
listener = socket.socket()
listener.bind(("127.0.0.1", foreign_port))
listener.listen(1)
config_path = self.config_home / "proxy/config"
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
"port = {}".format(managed_port), "port = {}".format(foreign_port)
),
encoding="utf-8",
)
marker = self.root / "substituted-child-ran"
for command in (
("status",),
("env",),
("run", "--", "touch", str(marker)),
):
with self.subTest(command=command):
result = self.run_cli(*command)
self.assertNotEqual(0, result.returncode)
self.assertIn("не совпадают", result.stderr)
self.assertFalse(marker.exists())
stopped = self.run_cli("stop")
stdout, stderr = process.communicate(timeout=4)
self.assertEqual(0, stopped.returncode, stopped.stderr)
self.assertEqual(0, process.returncode, stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
listener.settimeout(0.2)
with socket.create_connection(("127.0.0.1", foreign_port), timeout=1):
pass
listener.close()
def test_verified_child_must_own_configured_listener(self):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
wrong_port = self.free_port()
listener = socket.socket()
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", self.port))
listener.listen(16)
listener.settimeout(0.05)
serving = threading.Event()
serving.set()
def serve_foreign():
while serving.is_set():
try:
client, _address = listener.accept()
except socket.timeout:
continue
try:
client.settimeout(0.2)
request = client.recv(8192)
if b"ifconfig.me" in request:
body = b"203.0.113.88\n"
client.sendall(
b"HTTP/1.1 200 OK\r\nContent-Length: "
+ str(len(body)).encode("ascii")
+ b"\r\nConnection: close\r\n\r\n"
+ body
)
except OSError:
pass
finally:
client.close()
thread = threading.Thread(target=serve_foreign, daemon=True)
thread.start()
process = subprocess.Popen(
[str(CLI), "start"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=dict(self.environment, FAKE_XRAY_LISTEN_PORT=str(wrong_port)),
)
state_path = self.state_home / "xray-proxy/state.json"
deadline = time.monotonic() + 3
while time.monotonic() < deadline and not state_path.exists():
if process.poll() is not None:
break
time.sleep(0.02)
marker = self.root / "foreign-listener-child-ran"
results = [
self.run_cli("status"),
self.run_cli("env"),
self.run_cli("run", "--", "touch", str(marker)),
self.run_cli("stop"),
]
stdout, stderr = process.communicate(timeout=4)
serving.clear()
thread.join(timeout=1)
self.assertTrue(all(result.returncode != 0 for result in results))
self.assertTrue(all("listener" in result.stderr for result in results))
self.assertFalse(marker.exists())
self.assertNotEqual(0, process.returncode)
self.assertIn("listener", stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
with socket.create_connection(("127.0.0.1", self.port), timeout=1):
pass
listener.close()
def test_supervisor_forwards_original_signal_to_child(self):
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
with self.subTest(signum=signum):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
process = self.start()
state = json.loads(
(self.state_home / "xray-proxy/state.json").read_text(
encoding="utf-8"
)
)
os.kill(state["supervisor_pid"], signum)
stdout, stderr = process.communicate(timeout=4)
signals = [
entry["signum"]
for entry in self.log_entries()
if entry["kind"] == "signal"
]
self.assertEqual([signum], signals)
self.assertEqual(0, process.returncode, stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
self.log.unlink()
self.port = self.free_port()
self.write_machine_config("xray")
def test_absolute_relative_and_path_symlink_cli_invocations_are_manageable(self):
proxy_bin = self.root / "proxy-bin"
proxy_bin.mkdir()
(proxy_bin / "xray-proxy").symlink_to(CLI)
invocations = (
([str(CLI)], REPO_ROOT, self.environment),
([os.path.relpath(str(CLI), str(REPO_ROOT))], REPO_ROOT, self.environment),
(
["xray-proxy"],
self.root,
dict(
self.environment,
PATH=str(proxy_bin)
+ os.pathsep
+ str(self.bin_dir)
+ os.pathsep
+ os.environ["PATH"],
),
),
)
for command, cwd, environment in invocations:
with self.subTest(command=command):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
process = subprocess.Popen(
command + ["start"],
cwd=str(cwd),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
)
state_path = self.state_home / "xray-proxy/state.json"
deadline = time.monotonic() + 3
while time.monotonic() < deadline and not state_path.exists():
if process.poll() is not None:
break
time.sleep(0.02)
status = self.run_cli("status")
if status.returncode == 0:
stopped = self.run_cli("stop")
else:
stopped = None
if process.poll() is None:
os.kill(process.pid, signal.SIGTERM)
stdout, stderr = process.communicate(timeout=4)
self.assertEqual(0, status.returncode, status.stderr)
self.assertIsNotNone(stopped)
self.assertEqual(0, stopped.returncode, stopped.stderr)
self.assertEqual(0, process.returncode, stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
self.log.unlink()
self.port = self.free_port()
self.write_machine_config("xray")
if __name__ == "__main__":
unittest.main()