fix(proxy-client): замечания слепого ревью
- state.json снова хранит адрес и порт: при смене конфига у работающего Xray status/env/run отказывают и советуют перезапустить start. - Ошибки разбора ссылки называют файл профиля. - Тесты: отказ start при чужой версии Xray, ключи REALITY не попадают в вывод, Xray узнаётся после замены файла; машинный конфиг проверяется через CLI; тест пути по умолчанию не зависит от доступности Hiddify. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5ccc75419d
commit
09f57aa1e7
3 files changed
+168
-56
No files matched your search
@@ -2,6 +2,7 @@
|
||||
"""Команда proxy-client с управляемым Xray: argv → код выхода, вывод, файлы, процессы."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -21,6 +22,12 @@ PROFILE = (
|
||||
"vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none"
|
||||
"&sni=tls.example&fp=firefox&alpn=h2&host=host.example&path={}#Synthetic"
|
||||
).format(UUID, quote(PATH_SECRET, safe=""))
|
||||
PBK = "PUBLIC_KEY_SENTINEL_7f8a"
|
||||
SID = "SHORT_ID_SENTINEL_9b3c"
|
||||
REALITY_PROFILE = (
|
||||
"vless://{}@edge.example:443?type=tcp&security=reality&encryption=none"
|
||||
"&flow=xtls-rprx-vision&sni=front.example&fp=chrome&pbk={}&sid={}#Synthetic"
|
||||
).format(UUID, PBK, SID)
|
||||
READY = "остановка: Ctrl+C или proxy-client stop"
|
||||
|
||||
# Поддельный Xray — симлинк на интерпретатор Python: тогда /proc/PID/exe
|
||||
@@ -73,7 +80,8 @@ class ManagedXrayTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.root = Path(os.path.realpath(temporary.name))
|
||||
self.addCleanup(self.kill_strays)
|
||||
bin_dir = self.root / "bin"
|
||||
bin_dir.mkdir()
|
||||
self.xray = bin_dir / "xray"
|
||||
@@ -102,8 +110,8 @@ class ManagedXrayTest(unittest.TestCase):
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def write_profile(self, path, mode=0o600):
|
||||
path.write_text(PROFILE + "\n", encoding="utf-8")
|
||||
def write_profile(self, path, mode=0o600, text=PROFILE):
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
path.chmod(mode)
|
||||
return path
|
||||
|
||||
@@ -118,10 +126,10 @@ class ManagedXrayTest(unittest.TestCase):
|
||||
check=False,
|
||||
)
|
||||
|
||||
def start(self, **environment):
|
||||
def start(self, *arguments, **environment):
|
||||
"""Запускает start и ждёт строку готовности; возвращает процесс."""
|
||||
process = subprocess.Popen(
|
||||
[str(CLI), "start"],
|
||||
[str(CLI), "start"] + list(arguments),
|
||||
cwd=str(self.root),
|
||||
env=dict(self.environment, **environment),
|
||||
text=True,
|
||||
@@ -141,14 +149,17 @@ class ManagedXrayTest(unittest.TestCase):
|
||||
watchdog.cancel()
|
||||
self.fail("start не дошёл до готовности: " + process.communicate()[1])
|
||||
|
||||
def kill_strays(self):
|
||||
# Все процессы теста (CLI и поддельный Xray) работают в self.root.
|
||||
for entry in Path("/proc").iterdir():
|
||||
try:
|
||||
if os.readlink(str(entry / "cwd")) == str(self.root):
|
||||
os.kill(int(entry.name), signal.SIGKILL)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
def kill_leftovers(self, process):
|
||||
state = self.state_dir / "state.json"
|
||||
if state.exists():
|
||||
pid = json.loads(state.read_text(encoding="utf-8"))["xray_pid"]
|
||||
if pid_alive(pid):
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
self.kill_strays()
|
||||
process.communicate()
|
||||
|
||||
def finish(self, process):
|
||||
@@ -165,7 +176,7 @@ class ManagedXrayTest(unittest.TestCase):
|
||||
self.assertFalse((self.state_dir / "client.json").exists())
|
||||
|
||||
def assert_no_secrets(self, text):
|
||||
for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe="")):
|
||||
for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe=""), PBK, SID):
|
||||
self.assertNotIn(secret, text)
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
@@ -230,7 +241,13 @@ class ManagedXrayTest(unittest.TestCase):
|
||||
self.state_dir.mkdir(parents=True)
|
||||
(self.state_dir / "state.json").write_text(
|
||||
json.dumps(
|
||||
{"supervisor_pid": 999999998, "xray_pid": 999999999, "xray_path": "/x"}
|
||||
{
|
||||
"supervisor_pid": 999999998,
|
||||
"xray_pid": 999999999,
|
||||
"xray_path": "/x",
|
||||
"address": "127.0.0.1",
|
||||
"port": self.port,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -292,6 +309,76 @@ class ManagedXrayTest(unittest.TestCase):
|
||||
self.assertEqual(0, process.returncode)
|
||||
self.assertIn("Предупреждение: внешний IP", stderr)
|
||||
|
||||
def test_reality_secrets_stay_out_of_start_output(self):
|
||||
profile = self.write_profile(self.root / "reality.url", text=REALITY_PROFILE)
|
||||
process = self.start("-f", str(profile))
|
||||
process.send_signal(signal.SIGINT)
|
||||
stdout, stderr = self.finish(process)
|
||||
self.assertIn("Секреты REALITY: скрыты", stdout)
|
||||
self.assert_no_secrets(stdout + stderr)
|
||||
|
||||
def test_wrong_xray_version_is_refused_before_launch(self):
|
||||
(self.root / "version").write_text("print('Xray 26.3.26 (fake)')\n", encoding="utf-8")
|
||||
result = self.run_cli("start")
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("не сообщил версию Xray 26.3.27", result.stderr)
|
||||
self.assert_no_state_files()
|
||||
|
||||
def test_replaced_xray_file_is_still_recognised_and_stopped(self):
|
||||
# Нужен настоящий файл, а не симлинк: только тогда замена файла видна
|
||||
# в /proc/PID/exe как «(deleted)». Копия интерпретатора находит свою
|
||||
# стандартную библиотеку через ../lib.
|
||||
interpreter = Path(os.path.realpath(sys.executable))
|
||||
self.xray.unlink()
|
||||
shutil.copy2(str(interpreter), str(self.xray))
|
||||
(self.root / "lib").symlink_to(interpreter.parent.parent / "lib")
|
||||
process = self.start()
|
||||
xray_pid = self.xray_pid()
|
||||
|
||||
replacement = self.xray.with_name("xray.new")
|
||||
shutil.copy2(str(self.xray), str(replacement))
|
||||
os.replace(str(replacement), str(self.xray))
|
||||
self.assertTrue(os.readlink("/proc/{}/exe".format(xray_pid)).endswith(" (deleted)"))
|
||||
|
||||
status = self.run_cli("status")
|
||||
self.assertEqual(0, status.returncode, status.stderr)
|
||||
stopped = self.run_cli("stop")
|
||||
self.assertEqual(0, stopped.returncode, stopped.stderr)
|
||||
self.finish(process)
|
||||
self.assertFalse(pid_alive(xray_pid))
|
||||
self.assert_no_state_files()
|
||||
|
||||
def test_changed_machine_port_is_refused_while_xray_runs(self):
|
||||
process = self.start()
|
||||
managed_port = self.port
|
||||
with socket.socket() as other:
|
||||
other.bind(("127.0.0.1", 0))
|
||||
other.listen()
|
||||
self.port = other.getsockname()[1]
|
||||
self.write_machine_config()
|
||||
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(str(self.config_dir / "config"), result.stderr)
|
||||
self.assertIn(":{}".format(managed_port), result.stderr)
|
||||
self.assertIn("proxy-client stop", result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
process.send_signal(signal.SIGINT)
|
||||
self.finish(process)
|
||||
|
||||
def test_bad_link_error_names_profile_file(self):
|
||||
target = self.root / "real profile.url"
|
||||
target.write_text("vless://not-a-link\n", encoding="utf-8")
|
||||
target.chmod(0o600)
|
||||
link = self.root / "link.url"
|
||||
link.symlink_to(target)
|
||||
result = self.run_cli("start", "-f", str(link))
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("Ошибка: файл профиля {}: ".format(target), result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user