From a5db265f47b741343a0d9ac56c7362cfe266f44a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 09:34:57 -0400 Subject: [PATCH] =?UTF-8?q?refactor(proxy-client):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B8=D0=B7=D0=B1=D1=8B=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D1=83=D1=8E=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D1=83=20?= =?UTF-8?q?=D0=B8=D0=B7=20CLI=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Проверка «тот ли процесс»: /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) --- proxy-client/.local/bin/proxy-client | 1414 +++++++---------------- proxy-client/README.md | 61 +- tests/test_proxy_client.py | 1571 +++++++------------------- tests/test_proxy_client_managed.py | 1289 ++++----------------- 4 files changed, 1064 insertions(+), 3271 deletions(-) diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index c2a0c7a..1ca3079 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -19,14 +19,13 @@ import json import os import platform import re +import shlex import shutil import signal import socket -import stat import subprocess import tempfile import time -import urllib.error import urllib.request import uuid import zipfile @@ -46,16 +45,12 @@ DEFAULT_NO_PROXY_EXTRA = ( ".dementev.space", ) XRAY_VERSION = "26.3.27" -VERSION_LINE = re.compile( - r"^Xray ({0})(?: .*)?$".format(re.escape(XRAY_VERSION)) -) +VERSION_LINE = re.compile(r"Xray {}(?: .*)?".format(re.escape(XRAY_VERSION))) XRAY_RELEASE_BASE_URL = ( "https://github.com/XTLS/Xray-core/releases/download/v{}/".format(XRAY_VERSION) ) -MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 -MAX_DIGEST_BYTES = 4 * 1024 -MAX_XRAY_BYTES = 64 * 1024 * 1024 -STREAM_CHUNK_BYTES = 64 * 1024 +# Сколько секунд ждать остановки Xray, прежде чем добить его SIGKILL. +STOP_TIMEOUT = 5 QUERY_KEYS = { "type", "security", @@ -79,28 +74,12 @@ QUERY_KEYS = { "insecure", } ALPN_VALUES = {"http/1.1", "h2", "h3"} -STATE_KEYS = { - "supervisor_pid", - "xray_pid", - "xray_path", - "config_path", - "address", - "port", - "supervisor_path", - "supervisor_starttime", - "xray_starttime", - "boot_id", -} class ProxyError(Exception): pass -class StartLockBusyError(ProxyError): - pass - - class ProxyConfig: def __init__(self, proxy_type, name, address, port, no_proxy_extra): self.proxy_type = proxy_type @@ -111,13 +90,14 @@ class ProxyConfig: class VlessProfile: - def __init__(self, server, port, profile_id, query, alpn, extra): + def __init__(self, server, port, profile_id, query, alpn, extra, insecure): self.server = server self.port = port self.profile_id = profile_id self.query = query self.alpn = alpn self.extra = extra + self.insecure = insecure @property def transport(self): @@ -128,25 +108,15 @@ class VlessProfile: return self.query["security"] -def config_path(): - config_home = os.environ.get("XDG_CONFIG_HOME") - if config_home: - return Path(config_home).expanduser() / "proxy-client/config" - return Path.home() / ".config/proxy-client/config" - - -def proxy_client_config_home(): - config_home = os.environ.get("XDG_CONFIG_HOME") - if config_home: - return Path(config_home).expanduser() / "proxy-client" - return Path.home() / ".config/proxy-client" +def config_home(): + root = os.environ.get("XDG_CONFIG_HOME") + return (Path(root).expanduser() if root else Path.home() / ".config") / "proxy-client" def state_home(): - state_root = os.environ.get("XDG_STATE_HOME") - if state_root: - return Path(state_root).expanduser() / "proxy-client" - return Path.home() / ".local/state/proxy-client" + root = os.environ.get("XDG_STATE_HOME") + base = Path(root).expanduser() if root else Path.home() / ".local/state" + return base / "proxy-client" def state_path(): @@ -157,19 +127,15 @@ def generated_config_path(): return state_home() / "client.json" -def lock_path(): - return state_home() / "lock" - - def validate_address(value): try: - parsed = ipaddress.ip_address(value) + ipaddress.ip_address(value) except ValueError: pass else: if "%" in value: raise ProxyError("поле address не поддерживает IPv6 zone ID") - return parsed + return if ( not value or not value.isascii() @@ -177,90 +143,52 @@ def validate_address(value): or any(separator in value for separator in ("://", "@", "/", "[", "]", "%")) ): raise ProxyError("поле address должно быть IP-адресом или простым ASCII-именем") - return None - - -def parse_no_proxy_extra(value): - if value == "": - return () - items = tuple(item.strip() for item in value.split(",")) - if any(not item for item in items): - raise ProxyError("no_proxy_extra содержит пустой элемент") - return items - - -def default_config(): - return ProxyConfig( - "external", - DEFAULT_NAME, - DEFAULT_ADDRESS, - DEFAULT_PORT, - DEFAULT_NO_PROXY_EXTRA, - ) def load_config(): - path = config_path() + path = config_home() / "config" if not path.exists(): if path.is_symlink(): - raise ProxyError("машинный конфиг ссылается на отсутствующий файл: {}".format(path)) - return default_config() - if not path.is_file(): - raise ProxyError("машинный конфиг не является обычным файлом: {}".format(path)) - + raise ProxyError("машинный конфиг {} ссылается на отсутствующий файл".format(path)) + return ProxyConfig( + "external", DEFAULT_NAME, DEFAULT_ADDRESS, DEFAULT_PORT, DEFAULT_NO_PROXY_EXTRA + ) try: - text = path.read_text(encoding="utf-8") - except OSError as error: - raise ProxyError("не удалось прочитать машинный конфиг {}: {}".format(path, error)) + return parse_config(path.read_text(encoding="utf-8")) + except (OSError, ValueError, configparser.Error, ProxyError) as error: + raise ProxyError("машинный конфиг {}: {}".format(path, error)) - parser = configparser.ConfigParser( - interpolation=None, - strict=True, - allow_no_value=False, - empty_lines_in_values=False, - ) - try: - parser.read_string(text, source=str(path)) - except configparser.Error as error: - raise ProxyError("неверный машинный INI: {}".format(error)) +def parse_config(text): + parser = configparser.ConfigParser(interpolation=None, empty_lines_in_values=False) + parser.read_string(text, source="config") if parser.sections() != ["proxy"]: - raise ProxyError("машинный INI должен содержать только секцию [proxy]") - + raise ProxyError("нужна ровно одна секция [proxy]") section = parser["proxy"] - actual_keys = set(section.keys()) - if actual_keys != CONFIG_KEYS: - missing = sorted(CONFIG_KEYS - actual_keys) - unknown = sorted(actual_keys - CONFIG_KEYS) + keys = set(section) + if keys != CONFIG_KEYS: details = [] - if missing: - details.append("нет ключей: {}".format(", ".join(missing))) - if unknown: - details.append("неизвестные ключи: {}".format(", ".join(unknown))) + if CONFIG_KEYS - keys: + details.append("нет ключей: {}".format(", ".join(sorted(CONFIG_KEYS - keys)))) + if keys - CONFIG_KEYS: + details.append("неизвестные ключи: {}".format(", ".join(sorted(keys - CONFIG_KEYS)))) raise ProxyError("неверные ключи [proxy] ({})".format("; ".join(details))) - values = {key: section.get(key, raw=True) for key in CONFIG_KEYS} - - proxy_type = values["type"].strip() + proxy_type = section["type"] if proxy_type not in ("external", "xray"): raise ProxyError("поле type должно быть external или xray") - name = values["name"].strip() - address = values["address"].strip() - if not name: + if not section["name"]: raise ProxyError("поле name не должно быть пустым") - if not address: - raise ProxyError("поле address не должно быть пустым") + address = section["address"] validate_address(address) - - port_text = values["port"].strip() - if not re.fullmatch(r"[0-9]+", port_text): + if not re.fullmatch(r"[0-9]+", section["port"]) or not 1 <= int(section["port"]) <= 65535: raise ProxyError("поле port должно быть целым числом от 1 до 65535") - port = int(port_text) - if not 1 <= port <= 65535: - raise ProxyError("поле port должно быть в диапазоне 1–65535") - - no_proxy_extra = parse_no_proxy_extra(values["no_proxy_extra"]) - return ProxyConfig(proxy_type, name, address, port, no_proxy_extra) + no_proxy_extra = () + if section["no_proxy_extra"]: + no_proxy_extra = tuple(item.strip() for item in section["no_proxy_extra"].split(",")) + if not all(no_proxy_extra): + raise ProxyError("no_proxy_extra содержит пустой элемент") + return ProxyConfig(proxy_type, section["name"], address, int(section["port"]), no_proxy_extra) def preflight_timeout(): @@ -274,24 +202,21 @@ def preflight_timeout(): return timeout -def check_reachable(config, timeout): - connection = None +def check_reachable(config): try: - connection = socket.create_connection((config.address, config.port), timeout=timeout) + socket.create_connection((config.address, config.port), timeout=preflight_timeout()).close() except OSError as error: raise ProxyError( "прокси {} ({}:{}) недоступен: {}".format( config.name, config.address, config.port, error ) ) - finally: - if connection is not None: - connection.close() -def check_endpoint(address, port, name="управляемый Xray"): - config = ProxyConfig("xray", name, address, port, ()) - check_reachable(config, preflight_timeout()) +def check_proxy(config): + if config.proxy_type == "xray": + require_supervised_xray() + check_reachable(config) def proxy_host_for_url(address): @@ -325,49 +250,33 @@ def environment_values(config): def private_directory(path): path.mkdir(parents=True, exist_ok=True, mode=0o700) - try: - path.chmod(0o700) - except OSError as error: - raise ProxyError("не удалось закрыть служебный каталог: {}".format(error)) + path.chmod(0o700) def write_private_json(path, value): private_directory(path.parent) - try: - descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - json.dump( - value, - stream, - ensure_ascii=False, - sort_keys=True, - allow_nan=False, - ) - stream.write("\n") - path.chmod(0o600) - except (OSError, ValueError) as error: - raise ProxyError("не удалось записать закрытый служебный файл: {}".format(error)) + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False) + stream.write("\n") + path.chmod(0o600) def read_profile(path): - try: - resolved = path.expanduser().resolve(strict=True) - file_stat = resolved.stat() - except (OSError, RuntimeError) as error: - raise ProxyError("не удалось открыть файл профиля: {}".format(error)) - if not stat.S_ISREG(file_stat.st_mode): - raise ProxyError("файл профиля должен быть обычным файлом") - if file_stat.st_mode & 0o077: + # Права проверяем у конечного файла: у симлинка они ничего не значат. + resolved = path.expanduser().resolve(strict=True) + if resolved.stat().st_mode & 0o077: raise ProxyError( - "конечный файл профиля доступен группе или другим пользователям; выполните chmod 600" + "файл профиля {} доступен другим пользователям; выполните: chmod 600 {}".format( + resolved, shlex.quote(str(resolved)) + ) ) - try: - text = resolved.read_text(encoding="utf-8") - except OSError as error: - raise ProxyError("не удалось прочитать файл профиля: {}".format(error)) - lines = [line.strip() for line in text.splitlines() if line.strip()] + lines = [line.strip() for line in resolved.read_text(encoding="utf-8").splitlines()] + lines = [line for line in lines if line] if len(lines) != 1: - raise ProxyError("файл профиля должен содержать ровно одну непустую VLESS-ссылку") + raise ProxyError( + "файл профиля {} должен содержать ровно одну непустую VLESS-ссылку".format(resolved) + ) return lines[0] @@ -393,48 +302,15 @@ def parse_query(raw_query): def required(query, *keys): - missing = [key for key in keys if not query.get(key)] - if missing: + if not all(query.get(key) for key in keys): raise ProxyError("в профиле отсутствуют обязательные параметры") -def parse_boolean_fields(query): - values = [] - for key in ("allowInsecure", "insecure"): - if key in query: - if query[key] not in ("true", "false"): - raise ProxyError("legacy-поля принимают только true или false") - values.append(query[key] == "true") - if len(values) == 2 and values[0] != values[1]: - raise ProxyError("allowInsecure и insecure должны совпадать") - return values[0] if values else False - - -def parse_json_object(value): - def reject_duplicates(pairs): - result = {} - for key, item in pairs: - if key in result: - raise ProxyError("extra содержит повтор ключа JSON") - result[key] = item - return result - - def reject_constant(_constant): - raise ProxyError("extra содержит константу вне стандарта JSON") - - try: - parsed = json.loads( - value, - object_pairs_hook=reject_duplicates, - parse_constant=reject_constant, - ) - except ProxyError: - raise - except (TypeError, ValueError): - raise ProxyError("extra должен быть корректным JSON-объектом") - if not isinstance(parsed, dict): - raise ProxyError("extra должен быть JSON-объектом") - return parsed +def list_value(value): + items = value.split(",") + if not all(items): + raise ProxyError("списочный параметр содержит пустой элемент") + return items def parse_vless(url): @@ -478,14 +354,17 @@ def parse_vless(url): raise ProxyError("поддерживается только метаданные hiddify=1") if "core" in query and query["core"] != "xray": raise ProxyError("поддерживается только метаданные core=xray") - legacy_insecure = parse_boolean_fields(query) + # Xray 26.3.27 больше не умеет отключать проверку сертификата: поля + # принимаем, но не переносим. + insecure = any( + query.get(key, "").lower() in ("1", "true") for key in ("allowInsecure", "insecure") + ) alpn = None extra = None if query["type"] == "tcp" and query["security"] == "reality": required(query, "flow", "pbk", "sid") - forbidden = {"path", "mode", "extra", "pcs", "vcn", "core", "allowInsecure", "insecure"} - if forbidden.intersection(query): + if {"path", "mode", "extra", "pcs", "vcn", "core"}.intersection(query): raise ProxyError("параметры профиля несовместимы с TCP/REALITY") elif query["type"] == "xhttp" and query["security"] == "tls": required(query, "host", "path") @@ -500,23 +379,19 @@ def parse_vless(url): list_value(query["vcn"]) if "alpn" in query: alpn = list_value(query["alpn"]) - if not alpn or any(item not in ALPN_VALUES for item in alpn): + if any(item not in ALPN_VALUES for item in alpn): raise ProxyError("профиль содержит неподдерживаемый ALPN") if "extra" in query: - extra = parse_json_object(query["extra"]) + try: + extra = json.loads(query["extra"]) + except ValueError: + extra = None + if not isinstance(extra, dict): + raise ProxyError("extra должен быть JSON-объектом") else: raise ProxyError("поддерживаются только TCP/REALITY и XHTTP/TLS") - profile = VlessProfile(server, port, profile_id, query, alpn, extra) - profile.legacy_insecure = legacy_insecure - return profile - - -def list_value(value): - items = value.split(",") - if any(not item for item in items): - raise ProxyError("списочный параметр содержит пустой элемент") - return items + return VlessProfile(server, port, profile_id, query, alpn, extra, insecure) def build_xray_config(profile, local_address, local_port): @@ -585,30 +460,25 @@ def build_xray_config(profile, local_address, local_port): def resolve_xray(): found = shutil.which("xray") if found is None: - raise ProxyError("xray не найден в PATH; сначала выполните proxy-client install") - try: - return str(Path(found).resolve(strict=True)) - except OSError as error: - raise ProxyError("не удалось разрешить путь к xray: {}".format(error)) + raise ProxyError("xray не найден в PATH; установите его: proxy-client install") + return str(Path(found).resolve(strict=True)) -def xray_version(path): +def check_xray_version(path): try: completed = subprocess.run( [path, "version"], text=True, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=subprocess.DEVNULL, timeout=5, check=False, ) except (OSError, subprocess.TimeoutExpired): - raise ProxyError("не удалось безопасно определить версию выбранного xray") - first_line = completed.stdout.splitlines()[0] if completed.stdout.splitlines() else "" - match = VERSION_LINE.fullmatch(first_line) - if completed.returncode != 0 or match is None: - raise ProxyError("выбранный xray не сообщил точно поддерживаемую версию") - return match.group(1) + completed = None + lines = completed.stdout.splitlines() if completed else [] + if not lines or completed.returncode != 0 or not VERSION_LINE.fullmatch(lines[0]): + raise ProxyError("{} не сообщил версию Xray {}".format(path, XRAY_VERSION)) def xray_asset_for_architecture(machine): @@ -620,285 +490,79 @@ def xray_asset_for_architecture(machine): raise ProxyError("архитектура {} не поддерживается установщиком Xray".format(machine)) -def download_file(url, destination, max_bytes): +def download_file(url, destination): try: with urllib.request.urlopen(url, timeout=30) as response: - raw_length = response.headers.get("Content-Length") - if raw_length is None: - declared_length = None - elif re.fullmatch(r"[0-9]+", raw_length) is None: - raise ProxyError("сервер сообщил неверный Content-Length") - else: - declared_length = int(raw_length) - if declared_length > max_bytes: - raise ProxyError("скачиваемый файл превышает допустимый размер") with destination.open("wb") as stream: - actual_length = 0 - while True: - read_size = min( - STREAM_CHUNK_BYTES, max_bytes - actual_length + 1 - ) - chunk = response.read(read_size) - if not chunk: - break - actual_length += len(chunk) - if actual_length > max_bytes: - raise ProxyError("скачиваемый файл превышает допустимый размер") - if declared_length is not None and actual_length > declared_length: - raise ProxyError( - "фактический размер не совпадает с Content-Length" - ) - stream.write(chunk) - if declared_length is not None and actual_length != declared_length: - raise ProxyError("фактический размер не совпадает с Content-Length") - except ProxyError: - raise - except (OSError, urllib.error.URLError, http.client.HTTPException) as error: - raise ProxyError("не удалось скачать официальный Xray: {}".format(error)) + shutil.copyfileobj(response, stream) + except (OSError, http.client.HTTPException) as error: + raise ProxyError("не удалось скачать {}: {}".format(url, error)) -def expected_archive_sha256(digest_path): +def expected_sha256(digest_text): + for line in digest_text.splitlines(): + name, _, value = line.partition("=") + if name.strip() == "SHA2-256": + return value.strip().lower() + raise ProxyError("в файле .dgst нет строки SHA2-256=") + + +def install_xray(destination, downloader): + asset = xray_asset_for_architecture(platform.machine()) + destination.parent.mkdir(parents=True, exist_ok=True) + # Временный каталог рядом с назначением: os.replace тогда атомарен, а + # неудачная установка не трогает старый бинарник. + staging = Path(tempfile.mkdtemp(prefix=".xray-install-", dir=str(destination.parent))) try: - with digest_path.open("rb") as stream: - digest_bytes = stream.read(MAX_DIGEST_BYTES + 1) - except (OSError, UnicodeError) as error: - raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) - if len(digest_bytes) > MAX_DIGEST_BYTES: - raise ProxyError("файл SHA-256 превышает допустимый размер") - try: - digest_text = digest_bytes.decode("ascii") - except UnicodeError as error: - raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) - lines = digest_text.split("\n") - if lines and lines[-1] == "": - lines.pop() - expected_lengths = { - "MD5": 32, - "SHA1": 40, - "SHA2-256": 64, - "SHA2-512": 128, - } - records = {} - for line in lines: - match = re.fullmatch( - r"(MD5|SHA1|SHA2-256|SHA2-512)= ([0-9a-f]+)", line - ) - if match is None: - raise ProxyError("файл SHA-256 имеет неожиданный формат") - name, value = match.groups() - if name in records or len(value) != expected_lengths[name]: - raise ProxyError("файл SHA-256 имеет неожиданный формат") - records[name] = value - if set(records) != set(expected_lengths): - raise ProxyError("файл SHA-256 имеет неожиданный формат") - return records["SHA2-256"] - - -def archive_sha256(archive_path): - digest = hashlib.sha256() - total = 0 - try: - with archive_path.open("rb") as stream: - while True: - chunk = stream.read( - min(STREAM_CHUNK_BYTES, MAX_ARCHIVE_BYTES - total + 1) - ) - if not chunk: - break - total += len(chunk) - if total > MAX_ARCHIVE_BYTES: - raise ProxyError("архив Xray превышает допустимый размер") - digest.update(chunk) - except ProxyError: - raise - except OSError as error: - raise ProxyError("не удалось прочитать архив Xray: {}".format(error)) - return digest.hexdigest() - - -def extract_xray_candidate(archive_path, candidate_path): - try: - with zipfile.ZipFile(str(archive_path)) as archive: - members = [item for item in archive.infolist() if item.filename == "xray"] - if len(members) != 1 or members[0].is_dir(): - raise ProxyError("официальный архив не содержит ровно один файл xray") - if members[0].file_size > MAX_XRAY_BYTES: - raise ProxyError("извлекаемый xray превышает допустимый размер") - with archive.open(members[0]) as source: - descriptor = os.open( - str(candidate_path), - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - 0o600, - ) - with os.fdopen(descriptor, "wb") as destination: - extracted = 0 - while True: - chunk = source.read( - min(STREAM_CHUNK_BYTES, MAX_XRAY_BYTES - extracted + 1) - ) - if not chunk: - break - extracted += len(chunk) - if extracted > MAX_XRAY_BYTES: - raise ProxyError( - "извлекаемый xray превышает допустимый размер" - ) - destination.write(chunk) - if extracted != members[0].file_size: - raise ProxyError("размер извлечённого xray не совпадает с ZIP") - candidate_path.chmod(0o755) - except ProxyError: - raise - except (OSError, KeyError, zipfile.BadZipFile) as error: - raise ProxyError("не удалось извлечь xray из проверенного архива: {}".format(error)) - - -def install_destination(): - return Path.home() / ".local/bin/xray" - - -def require_install_parent_within_home(parent): - try: - home = Path.home().resolve(strict=True) - resolved_parent = parent.resolve(strict=False) - resolved_parent.relative_to(home) - except (OSError, RuntimeError, ValueError): - raise ProxyError( - "каталог установки должен находиться внутри домашнего каталога пользователя" - ) - - -def require_install_safe_managed_state(): - path = state_path() - try: - path.lstat() - except FileNotFoundError: - return - except OSError: - raise ProxyError( - "состояние managed-режима не удалось проверить; сначала выполните proxy-client stop" - ) - try: - state = read_managed_state() - outcome = managed_state_outcome(state) - except ProxyError: - raise ProxyError( - "состояние managed-режима не подтверждено; сначала выполните proxy-client stop" - ) - if outcome != "stale": - raise ProxyError( - "managed Xray работает или не подтверждён; сначала выполните proxy-client stop" - ) - - -def acquire_install_lock(): - try: - return acquire_start_lock() - except StartLockBusyError: - raise ProxyError( - "другая операция удерживает блокировку; сначала выполните proxy-client stop" - ) - - -def existing_installation_outcome(destination, replace): - try: - info = destination.lstat() - except FileNotFoundError: - return "install" - except OSError as error: - raise ProxyError("не удалось проверить назначение {}: {}".format(destination, error)) - if stat.S_ISDIR(info.st_mode): - raise ProxyError("назначение {} является каталогом".format(destination)) - if replace: - return "install" - if stat.S_ISREG(info.st_mode) and os.access(str(destination), os.X_OK): + archive = staging / asset + digest = staging / (asset + ".dgst") + downloader(XRAY_RELEASE_BASE_URL + asset, archive) + downloader(XRAY_RELEASE_BASE_URL + asset + ".dgst", digest) + expected = expected_sha256(digest.read_text(encoding="ascii", errors="replace")) + if hashlib.sha256(archive.read_bytes()).hexdigest() != expected: + raise ProxyError("SHA-256 архива {} не совпадает с {}.dgst".format(asset, asset)) try: - xray_version(str(destination)) - except ProxyError: - pass - else: - return "already-installed" - raise ProxyError( - "назначение {} уже существует; для проверенной замены используйте --replace".format( - destination - ) - ) - - -def verify_installed_xray_on_path(destination): - found = shutil.which("xray") - try: - matches = found is not None and Path(found).resolve(strict=True) == destination.resolve( - strict=True - ) - except (OSError, RuntimeError): - matches = False - if not matches: - raise ProxyError( - "Xray установлен в {}, но PATH выбирает другой файл; добавьте ~/.local/bin в начало PATH".format( - destination - ) - ) + with zipfile.ZipFile(str(archive)) as bundle: + candidate = Path(bundle.extract("xray", str(staging))) + except (KeyError, zipfile.BadZipFile) as error: + raise ProxyError("не удалось извлечь xray из {}: {}".format(asset, error)) + candidate.chmod(0o755) + check_xray_version(str(candidate)) + os.replace(str(candidate), str(destination)) + finally: + shutil.rmtree(str(staging), ignore_errors=True) def command_install(replace=False, downloader=download_file): - destination = install_destination() - lock = acquire_install_lock() - try: - require_install_safe_managed_state() - require_install_parent_within_home(destination.parent) - outcome = existing_installation_outcome(destination, replace) - if outcome == "already-installed": - print("Xray {} уже установлен: {}".format(XRAY_VERSION, destination)) - else: - asset = xray_asset_for_architecture(platform.machine()) - try: - destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755) - except OSError as error: - raise ProxyError( - "не удалось создать каталог установки {}: {}".format( - destination.parent, error - ) + destination = Path.home() / ".local/bin/xray" + if destination.is_dir(): + raise ProxyError("назначение {} является каталогом".format(destination)) + if os.path.lexists(str(destination)) and not replace: + try: + check_xray_version(str(destination)) + except ProxyError: + raise ProxyError( + "{} уже существует и это не Xray {}; заменить: proxy-client install --replace".format( + destination, XRAY_VERSION ) - require_install_parent_within_home(destination.parent) - if not destination.parent.is_dir(): - raise ProxyError( - "каталог установки {} не является каталогом".format(destination.parent) - ) - staging = Path( - tempfile.mkdtemp(prefix=".xray-install-", dir=str(destination.parent)) ) - try: - staging.chmod(0o700) - archive_path = staging / asset - digest_path = staging / (asset + ".dgst") - candidate_path = staging / "xray" - downloader( - XRAY_RELEASE_BASE_URL + asset, - archive_path, - MAX_ARCHIVE_BYTES, - ) - downloader( - XRAY_RELEASE_BASE_URL + asset + ".dgst", - digest_path, - MAX_DIGEST_BYTES, - ) - expected = expected_archive_sha256(digest_path) - if archive_sha256(archive_path) != expected: - raise ProxyError("SHA-256 архива Xray не совпадает") - extract_xray_candidate(archive_path, candidate_path) - xray_version(str(candidate_path)) - os.replace(str(candidate_path), str(destination)) - finally: - shutil.rmtree(str(staging), ignore_errors=True) - print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) - finally: - lock.close() - verify_installed_xray_on_path(destination) + print("Xray {} уже установлен: {}".format(XRAY_VERSION, destination)) + else: + install_xray(destination, downloader) + print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) + if managed_xray_running(): + print("Работающий Xray остался прежним; перезапустите proxy-client start") + found = shutil.which("xray") + if found is None or Path(found).resolve() != destination.resolve(): + raise ProxyError( + "Xray установлен в {}, но PATH выбирает другой xray или не находит его; " + 'выполните: export PATH="$HOME/.local/bin:$PATH"'.format(destination) + ) return 0 def run_xray_test(path, config_path): + # Вывод Xray глушим: он может процитировать секреты из конфига. try: completed = subprocess.run( [path, "run", "-test", "-c", str(config_path)], @@ -908,204 +572,45 @@ def run_xray_test(path, config_path): check=False, ) except (OSError, subprocess.TimeoutExpired): - raise ProxyError("проверка созданной конфигурации Xray не выполнена") - if completed.returncode != 0: + completed = None + if completed is None or completed.returncode != 0: raise ProxyError("созданная конфигурация не прошла xray run -test") -def process_alive(pid): - try: - Path("/proc/{}".format(pid)).stat() - return True - except FileNotFoundError: - return False - except OSError: - return True - - -def parse_proc_stat_starttime(text): - comm_end = text.rfind(")") - if comm_end < 0: - raise ValueError("missing comm terminator") - fields = text[comm_end + 1 :].split() - if len(fields) <= 19: - raise ValueError("missing starttime") - return int(fields[19]) - - -def process_starttime(pid): - try: - text = Path("/proc/{}/stat".format(pid)).read_text(encoding="ascii") - return parse_proc_stat_starttime(text) - except (OSError, UnicodeError, ValueError): - return None - - -def process_executable(pid): - try: - return str(Path("/proc/{}/exe".format(pid)).resolve(strict=True)) - except (OSError, RuntimeError): - return None - - -def read_boot_id(): - try: - value = Path("/proc/sys/kernel/random/boot_id").read_text( - encoding="ascii" - ).strip() - except (OSError, UnicodeError): - return None - return value or None - - -def require_boot_id(): - value = read_boot_id() - if value is None: - raise ProxyError("не удалось определить идентификатор текущей загрузки") - return value - - -def process_cmdline(pid): - try: - raw = Path("/proc/{}/cmdline".format(pid)).read_bytes() - except OSError: - return [] - return [item.decode("utf-8", "surrogateescape") for item in raw.split(b"\0") if item] - - -def process_argument_resolves_to(pid, arguments, expected_path): - try: - process_cwd = Path("/proc/{}/cwd".format(pid)).resolve(strict=True) - except (OSError, RuntimeError): - return False - for argument in arguments: - if not argument or "\0" in argument: - continue - candidate = Path(argument) - if not candidate.is_absolute(): - candidate = process_cwd / candidate - try: - if candidate.resolve(strict=True) == expected_path: - return True - except (OSError, RuntimeError): - continue - return False - - -def read_managed_state(): +def read_state(): path = state_path() try: - info = path.lstat() - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): - raise ProxyError("состояние управляемого Xray не подтверждено") - if info.st_uid != os.getuid() or info.st_mode & 0o077: - raise ProxyError("состояние управляемого Xray не подтверждено") - value = json.loads(path.read_text(encoding="utf-8")) + state = json.loads(path.read_text(encoding="utf-8")) + return {key: state[key] for key in ("supervisor_pid", "xray_pid", "xray_path")} except FileNotFoundError: raise ProxyError("управляемый Xray не запущен; выполните proxy-client start") - except ProxyError: - raise - except (OSError, ValueError, TypeError): - raise ProxyError("состояние управляемого Xray не подтверждено") - if not isinstance(value, dict) or set(value) != STATE_KEYS: - raise ProxyError("состояние управляемого Xray не подтверждено") - integer_keys = ( - "supervisor_pid", - "xray_pid", - "port", - "supervisor_starttime", - "xray_starttime", - ) - if not all( - isinstance(value[key], int) and not isinstance(value[key], bool) - for key in integer_keys - ): - raise ProxyError("состояние управляемого Xray не подтверждено") - string_keys = ( - "xray_path", - "config_path", - "address", - "supervisor_path", - "boot_id", - ) - if not all(isinstance(value[key], str) for key in string_keys): - raise ProxyError("состояние управляемого Xray не подтверждено") - return value + except (ValueError, TypeError, KeyError): + raise ProxyError( + "файл состояния {} повреждён; если Xray не запущен, выполните: rm {}".format( + path, shlex.quote(str(path)) + ) + ) -def supervisor_identity_matches(state): - pid = state["supervisor_pid"] - if pid <= 1: - return False - arguments = process_cmdline(pid) - if len(arguments) < 2: - return False - if not process_argument_resolves_to( - pid, [arguments[1]], Path(state["supervisor_path"]) - ): - return False - return process_starttime(pid) == state["supervisor_starttime"] - - -def xray_identity_matches(state): - pid = state["xray_pid"] - if pid <= 1 or process_executable(pid) != state["xray_path"]: - return False - return process_starttime(pid) == state["xray_starttime"] - - -def wait_for_child_starttime(child): - while True: - value = process_starttime(child.pid) - if value is not None: - return value - if child.poll() is not None: - raise ProxyError("Xray завершился до сохранения идентичности процесса") - time.sleep(0.01) - - -def signal_supervisor(state, signal_number): - if read_boot_id() != state["boot_id"] or not supervisor_identity_matches(state): - return False +def xray_running(state): + # Процесс с этим PID должен исполнять тот самый xray. После + # install --replace ядро дописывает к пути старого файла « (deleted)». try: - os.kill(state["supervisor_pid"], signal_number) + executable = os.readlink("/proc/{}/exe".format(state["xray_pid"])) except OSError: return False - return True + return executable in (state["xray_path"], state["xray_path"] + " (deleted)") -def signal_xray(state, signal_number): - if read_boot_id() != state["boot_id"] or not xray_identity_matches(state): - return False +def supervisor_running(state): try: - os.kill(state["xray_pid"], signal_number) + cmdline = Path("/proc/{}/cmdline".format(state["supervisor_pid"])).read_bytes() except OSError: return False - return True + return b"proxy-client" in cmdline -def managed_state_outcome(state): - current_boot_id = read_boot_id() - if current_boot_id is None: - return "unverifiable" - if current_boot_id != state["boot_id"]: - return "stale" - if state["supervisor_pid"] == state["xray_pid"]: - return "unverifiable" - supervisor_alive = process_alive(state["supervisor_pid"]) - xray_alive = process_alive(state["xray_pid"]) - if not supervisor_alive and not xray_alive: - return "stale" - supervisor_matches = supervisor_identity_matches(state) - xray_matches = xray_identity_matches(state) - if supervisor_matches and xray_matches: - return "healthy" - if not supervisor_alive and xray_matches: - return "orphan" - return "unverifiable" - - -def cleanup_stale_files(): +def remove_state_files(): for path in (state_path(), generated_config_path()): try: path.unlink() @@ -1113,204 +618,45 @@ def cleanup_stale_files(): pass -def unverifiable_state_error(): - return ProxyError( - "личность процесса не подтверждена; сигналы не отправлены, " - "удалите state.json и client.json вручную после проверки процессов" - ) - - -def verify_managed_state(config=None, require_port=True): - state = read_managed_state() - outcome = managed_state_outcome(state) - if outcome == "stale": - lock = acquire_start_lock() - try: - state = read_managed_state() - outcome = managed_state_outcome(state) - if outcome == "stale": - cleanup_stale_files() - raise ProxyError("устаревшее состояние управляемого Xray очищено") - finally: - lock.close() - if outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; выполните proxy-client stop") - if outcome != "healthy": - raise unverifiable_state_error() - if config is not None and ( - config.proxy_type != "xray" - or config.address != state["address"] - or config.port != state["port"] - ): - raise ProxyError("настройки и состояние управляемого Xray не совпадают") - if require_port: - check_endpoint(state["address"], state["port"]) +def managed_state(): + """Состояние работающего Xray; устаревшие служебные файлы удаляет.""" + state = read_state() + if not xray_running(state): + remove_state_files() + raise ProxyError("управляемый Xray не запущен; устаревшие служебные файлы удалены") return state -def acquire_start_lock(): - private_directory(state_home()) +def managed_xray_running(): try: - descriptor = os.open(str(lock_path()), os.O_RDWR | os.O_CREAT, 0o600) - lock = os.fdopen(descriptor, "r+") - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - os.chmod(str(lock_path()), 0o600) - except BlockingIOError: - if "lock" in locals(): - lock.close() - raise StartLockBusyError("другой управляемый запуск уже удерживает блокировку") - except OSError as error: - if "lock" in locals(): - lock.close() - elif "descriptor" in locals(): - os.close(descriptor) - raise ProxyError("не удалось открыть файл блокировки: {}".format(error)) - return lock - - -def safe_summary(profile, local_port): - query = profile.query - print( - "Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format( - profile.server, - profile.port, - "RAW" if profile.transport == "tcp" else "XHTTP", - profile.security.upper(), - query["sni"], - query["fp"], - local_port, - ), - flush=True, - ) - if profile.transport == "tcp": - if "host" in query or "alpn" in query: - print("TCP/REALITY: Host и ALPN из ссылки не используются", flush=True) - print("Секреты REALITY: скрыты", flush=True) - else: - print( - "XHTTP: Host {}, длина пути: {}, ALPN {}".format( - query["host"], len(query["path"]), query.get("alpn", "не задан") - ), - flush=True, - ) - if profile.query["encryption"] != "none": - print("VLESS-шифрование: mlkem768x25519plus", flush=True) - print("UUID и ключи: скрыты", flush=True) - - -def warn_legacy(profile): - if profile.legacy_insecure: - sys.stderr.write( - "Предупреждение: legacy insecure не переносится; сертификат проверяется обычным способом.\n" - ) - - -def diagnose_ip(config): - connection = http.client.HTTPConnection( - config.address, config.port, timeout=preflight_timeout() - ) - try: - connection.request( - "GET", - "http://ifconfig.me/ip", - headers={"Host": "ifconfig.me", "User-Agent": "proxy-client/1"}, - ) - response = connection.getresponse() - address = response.read(256).decode("ascii", "replace").strip() - if response.status != 200 or not address: - raise ValueError("empty response") - parsed_address = ipaddress.ip_address(address) - print("Внешний IP через прокси: {}".format(parsed_address), flush=True) - except (OSError, ValueError, http.client.HTTPException): - sys.stderr.write( - "Предупреждение: внешний IP через прокси определить не удалось; Xray продолжает работу.\n" - ) - finally: - connection.close() - - -def cleanup_managed_files(supervisor_pid): - try: - current = read_managed_state() - if current["supervisor_pid"] == supervisor_pid: - state_path().unlink() + return xray_running(read_state()) except ProxyError: - pass - try: - generated_config_path().unlink() - except FileNotFoundError: - pass + return False -def command_status(config): - if config.proxy_type == "external": - check_reachable(config, preflight_timeout()) - print( - "{} (external) доступен по адресу {}:{}".format( - config.name, config.address, config.port - ) +def require_supervised_xray(): + if not supervisor_running(managed_state()): + raise ProxyError( + "Xray работает без proxy-client start (его процесс завершён); " + "выполните proxy-client stop" ) - else: - verify_managed_state(config) - print( - "{}: управляемый Xray работает на {}:{}".format( - config.name, config.address, config.port - ) - ) - return 0 -def command_env(config): - if config.proxy_type == "external": - check_reachable(config, preflight_timeout()) - else: - verify_managed_state(config) - lines = ["{}={}".format(key, value) for key, value in environment_values(config)] - sys.stdout.write("\n".join(lines) + "\n") - return 0 +def wait_until(condition, timeout): + deadline = time.monotonic() + timeout + while not condition(): + if time.monotonic() > deadline: + return False + time.sleep(0.05) + return True -def command_run(config, remainder): - if not remainder or remainder[0] != "--" or len(remainder) == 1: - raise ProxyError("использование: proxy-client run -- COMMAND ARG...") - command = remainder[1:] - if shutil.which(command[0]) is None: - sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) - return 127 - - if config.proxy_type == "external": - check_reachable(config, preflight_timeout()) - else: - verify_managed_state(config) - child_environment = os.environ.copy() - child_environment.update(dict(environment_values(config))) +def port_open(address, port): try: - completed = subprocess.run(command, env=child_environment, check=False) - except FileNotFoundError: - sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) - return 127 - except OSError as error: - sys.stderr.write("Ошибка: не удалось запустить {}: {}\n".format(command[0], error)) - return 126 - if completed.returncode < 0: - return 128 - completed.returncode - return completed.returncode - - -def command_start(config, profile_file): - if config.proxy_type == "external": - raise ProxyError("start доступен только для type=xray; измените машинный конфиг") - try: - address = ipaddress.ip_address(config.address) - except ValueError: - raise ProxyError("для type=xray поле address должно быть loopback IP-адресом") - if not address.is_loopback: - raise ProxyError("для type=xray требуется loopback IP-адрес") - lock = acquire_start_lock() - try: - return command_start_locked(config, profile_file, address) - finally: - lock.close() + socket.create_connection((address, port), timeout=0.2).close() + except OSError: + return False + return True def check_port_available(address, port): @@ -1326,195 +672,205 @@ def check_port_available(address, port): raise ProxyError("локальный порт {} занят или недоступен".format(port)) -def command_start_locked(config, profile_file, address): - if state_path().exists() or state_path().is_symlink(): - try: - existing_state = read_managed_state() - except ProxyError: - raise unverifiable_state_error() - outcome = managed_state_outcome(existing_state) - if outcome == "stale": - cleanup_stale_files() - elif outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; сначала выполните proxy-client stop") - elif outcome == "healthy": - raise ProxyError("управляемый Xray уже запущен") - else: - raise unverifiable_state_error() +def acquire_start_lock(): + private_directory(state_home()) + lock = (state_home() / "lock").open("a") + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + lock.close() + raise ProxyError("управляемый Xray уже запущен другим proxy-client start") + return lock - check_port_available(address, config.port) - profile_path = ( - Path(profile_file) - if profile_file - else proxy_client_config_home() / "default.url" +def print_summary(profile, local_port): + query = profile.query + print( + "Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format( + profile.server, + profile.port, + "RAW" if profile.transport == "tcp" else "XHTTP", + profile.security.upper(), + query["sni"], + query["fp"], + local_port, + ) ) - raw_profile = read_profile(profile_path) + if profile.transport == "tcp": + if "host" in query or "alpn" in query: + print("TCP/REALITY: Host и ALPN из ссылки не используются") + print("Секреты REALITY: скрыты") + else: + print( + "XHTTP: Host {}, длина пути: {}, ALPN {}".format( + query["host"], len(query["path"]), query.get("alpn", "не задан") + ) + ) + if query["encryption"] != "none": + print("VLESS-шифрование: mlkem768x25519plus") + print("UUID и ключи: скрыты", flush=True) + if profile.insecure: + sys.stderr.write( + "Предупреждение: allowInsecure/insecure не переносятся; " + "сертификат проверяется обычным способом.\n" + ) + + +def diagnose_ip(config): + connection = http.client.HTTPConnection( + config.address, config.port, timeout=preflight_timeout() + ) + try: + connection.request( + "GET", + "http://ifconfig.me/ip", + headers={"Host": "ifconfig.me", "User-Agent": "proxy-client/1"}, + ) + response = connection.getresponse() + address = response.read(256).decode("ascii", "replace").strip() + if response.status != 200: + raise ValueError("unexpected status") + print("Внешний IP через прокси: {}".format(ipaddress.ip_address(address)), flush=True) + except (OSError, ValueError, http.client.HTTPException): + sys.stderr.write( + "Предупреждение: внешний IP через прокси определить не удалось; Xray продолжает работу.\n" + ) + finally: + connection.close() + + +def exit_code(returncode): + # Как в shell: убитый сигналом N процесс даёт 128+N. + return 128 - returncode if returncode < 0 else returncode + + +def interrupt(_signum, _frame): + raise KeyboardInterrupt + + +def command_status(config): + check_proxy(config) + if config.proxy_type == "external": + print("{} (external) доступен по адресу {}:{}".format(config.name, config.address, config.port)) + else: + print("{}: управляемый Xray работает на {}:{}".format(config.name, config.address, config.port)) + return 0 + + +def command_env(config): + check_proxy(config) + lines = ["{}={}".format(key, value) for key, value in environment_values(config)] + sys.stdout.write("\n".join(lines) + "\n") + return 0 + + +def command_run(config, remainder): + if not remainder or remainder[0] != "--" or len(remainder) == 1: + raise ProxyError("использование: proxy-client run -- COMMAND ARG...") + command = remainder[1:] + if shutil.which(command[0]) is None: + sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) + return 127 + check_proxy(config) + child_environment = os.environ.copy() + child_environment.update(dict(environment_values(config))) + try: + completed = subprocess.run(command, env=child_environment, check=False) + except OSError as error: + sys.stderr.write("Ошибка: не удалось запустить {}: {}\n".format(command[0], error)) + return 126 + return exit_code(completed.returncode) + + +def command_start(config, profile_file): + if config.proxy_type != "xray": + raise ProxyError( + "start доступен только для type=xray; измените {}".format(config_home() / "config") + ) + try: + loopback = ipaddress.ip_address(config.address).is_loopback + except ValueError: + loopback = False + if not loopback: + raise ProxyError("для type=xray поле address должно быть loopback-адресом, например 127.0.0.1") + lock = acquire_start_lock() + try: + return run_managed_xray(config, profile_file) + finally: + lock.close() + + +def run_managed_xray(config, profile_file): + if state_path().exists(): + if xray_running(read_state()): + raise ProxyError( + "Xray остался от прошлого proxy-client start; сначала выполните proxy-client stop" + ) + remove_state_files() + check_port_available(ipaddress.ip_address(config.address), config.port) + profile_path = Path(profile_file) if profile_file else config_home() / "default.url" + profile = parse_vless(read_profile(profile_path)) executable = resolve_xray() - version = xray_version(executable) - profile = parse_vless(raw_profile) - generated = build_xray_config(profile, config.address, config.port) - write_private_json(generated_config_path(), generated) + check_xray_version(executable) + write_private_json( + generated_config_path(), build_xray_config(profile, config.address, config.port) + ) + child = None try: run_xray_test(executable, generated_config_path()) - print("Xray: {} ({})".format(executable, version), flush=True) - safe_summary(profile, config.port) - warn_legacy(profile) - stopping = {"signal": None} - - def request_stop(signal_number, _frame): - if stopping["signal"] is None: - stopping["signal"] = signal_number - - previous = {} - for handled in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - previous[handled] = signal.signal(handled, request_stop) - boot_id = require_boot_id() - supervisor_path = str(Path(__file__).resolve(strict=True)) - supervisor_starttime = process_starttime(os.getpid()) - if supervisor_starttime is None: - raise ProxyError("не удалось сохранить идентичность супервизора") - child = subprocess.Popen( - [executable, "run", "-c", str(generated_config_path())] + print("Xray: {} ({})".format(executable, XRAY_VERSION)) + print_summary(profile, config.port) + # SIGTERM (от proxy-client stop) и SIGHUP обрабатываем как Ctrl+C. + signal.signal(signal.SIGTERM, interrupt) + signal.signal(signal.SIGHUP, interrupt) + child = subprocess.Popen([executable, "run", "-c", str(generated_config_path())]) + write_private_json( + state_path(), + {"supervisor_pid": os.getpid(), "xray_pid": child.pid, "xray_path": executable}, ) - xray_starttime = wait_for_child_starttime(child) - state = { - "supervisor_pid": os.getpid(), - "xray_pid": child.pid, - "xray_path": executable, - "config_path": str(generated_config_path()), - "address": config.address, - "port": config.port, - "supervisor_path": supervisor_path, - "supervisor_starttime": supervisor_starttime, - "xray_starttime": xray_starttime, - "boot_id": boot_id, - } - write_private_json(state_path(), state) - deadline = time.monotonic() + max(1.0, preflight_timeout()) - last_error = None - while time.monotonic() < deadline: - if child.poll() is not None: - raise ProxyError("Xray завершился до готовности локального порта") - try: - check_endpoint(config.address, config.port) - last_error = None - break - except ProxyError as error: - last_error = error - time.sleep(0.03) - if last_error is not None: - raise ProxyError( - "локальный порт {} занят или недоступен".format(config.port) - ) + if not wait_until( + lambda: port_open(config.address, config.port), max(1.0, preflight_timeout()) + ): + raise ProxyError("Xray не открыл локальный порт {}".format(config.port)) diagnose_ip(config) - while child.poll() is None and stopping["signal"] is None: - time.sleep(0.05) - if stopping["signal"] is not None and child.poll() is None: - if not signal_xray(state, stopping["signal"]): - if child.poll() is None: - raise unverifiable_state_error() - else: - try: - child.wait(timeout=stop_timeout()) - except subprocess.TimeoutExpired: - if not signal_xray(state, signal.SIGKILL): - if child.poll() is None: - raise unverifiable_state_error() - else: - child.wait() - return_code = child.wait() - if stopping["signal"] is not None: - return 0 - if return_code < 0: - return 128 - return_code - return return_code + print( + "Управляемый Xray работает на {}:{}; остановка: Ctrl+C или proxy-client stop".format( + config.address, config.port + ), + flush=True, + ) + return exit_code(child.wait()) + except KeyboardInterrupt: + return 0 finally: - if "previous" in locals(): - for handled, handler in previous.items(): - signal.signal(handled, handler) - preserve_managed_files = False - if "child" in locals() and child.poll() is None and "state" in locals(): - if signal_xray(state, signal.SIGTERM): - try: - child.wait(timeout=stop_timeout()) - except subprocess.TimeoutExpired: - if signal_xray(state, signal.SIGKILL): - child.wait() - elif child.poll() is None: - preserve_managed_files = True - elif child.poll() is None: - preserve_managed_files = True - elif "child" in locals() and child.poll() is None: - preserve_managed_files = True - if preserve_managed_files: - raise unverifiable_state_error() - cleanup_managed_files(os.getpid()) - - -def stop_timeout(): - raw = os.environ.get("PROXY_CLIENT_STOP_TIMEOUT", "5") - try: - value = float(raw) - if not value > 0: - raise ValueError - except ValueError: - raise ProxyError("PROXY_CLIENT_STOP_TIMEOUT должен быть положительным числом секунд") - return value - - -def stop_managed_state(timeout, state, outcome): - if outcome == "stale": - cleanup_stale_files() - raise ProxyError("устаревшее состояние управляемого Xray очищено") - if outcome == "unverifiable": - raise unverifiable_state_error() - if outcome == "orphan": - if not signal_xray(state, signal.SIGTERM): - raise ProxyError("личность Xray-сироты изменилась; сигнал не отправлен") - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not process_alive(state["xray_pid"]): - cleanup_stale_files() - print("Xray-сирота остановлен, служебные файлы удалены") - return 0 - time.sleep(0.05) - if not signal_xray(state, signal.SIGKILL): - raise ProxyError("личность Xray-сироты изменилась; SIGKILL не отправлен") - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not process_alive(state["xray_pid"]): - cleanup_stale_files() - print("Xray-сирота остановлен, служебные файлы удалены") - return 0 - time.sleep(0.05) - raise ProxyError("остановка Xray-сироты после SIGKILL превысила таймаут") - - check_endpoint(state["address"], state["port"]) - if not signal_supervisor(state, signal.SIGTERM): - raise ProxyError("не удалось послать сигнал подтверждённому супервизору") - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not process_alive(state["xray_pid"]) and not state_path().exists(): - print("Управляемый Xray остановлен") - return 0 - time.sleep(0.05) - raise ProxyError("штатная остановка управляемого Xray превысила таймаут") + if child is not None and child.poll() is None: + child.terminate() + try: + child.wait(STOP_TIMEOUT) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + remove_state_files() def command_stop(): - timeout = stop_timeout() - state = read_managed_state() - outcome = managed_state_outcome(state) - if outcome in ("stale", "orphan"): - lock = acquire_start_lock() - try: - state = read_managed_state() - outcome = managed_state_outcome(state) - return stop_managed_state(timeout, state, outcome) - finally: - lock.close() - return stop_managed_state(timeout, state, outcome) + state = managed_state() + # Живой супервизор сам остановит Xray; иначе Xray гасим напрямую. + running_supervisor = supervisor_running(state) + target = state["supervisor_pid"] if running_supervisor else state["xray_pid"] + os.kill(target, signal.SIGTERM) + + def stopped(): + return not xray_running(state) and not supervisor_running(state) + + if not wait_until(stopped, STOP_TIMEOUT + 1): + if xray_running(state): + os.kill(state["xray_pid"], signal.SIGKILL) + if not wait_until(stopped, STOP_TIMEOUT): + raise ProxyError("управляемый Xray не остановился за {} с".format(2 * STOP_TIMEOUT + 1)) + remove_state_files() + print("Управляемый Xray остановлен") + return 0 def build_parser(): @@ -1525,7 +881,7 @@ def build_parser(): subparsers = parser.add_subparsers(dest="subcommand") install_parser = subparsers.add_parser("install", help="установить проверенный Xray") install_parser.add_argument( - "--replace", action="store_true", help="проверенно заменить существующее назначение" + "--replace", action="store_true", help="заменить существующий ~/.local/bin/xray" ) subparsers.add_parser("status", help="проверить доступность активного прокси") subparsers.add_parser("env", help="вывести восемь proxy-переменных") @@ -1559,7 +915,7 @@ def main(): return command_run(config, arguments.arguments) if arguments.subcommand == "start": return command_start(config, arguments.profile_file) - except ProxyError as error: + except (ProxyError, OSError) as error: sys.stderr.write("Ошибка: {}\n".format(error)) return 1 return 2 diff --git a/proxy-client/README.md b/proxy-client/README.md index 0d2617f..844a20d 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -19,13 +19,16 @@ CLI требует Python 3.8+ и использует только станда ~/src/proxy-client/install.sh ``` -При наличии Stow установщик использует его, иначе создаёт те же три симлинка -напрямую: CLI, Bash-модуль и каталог шаблонов. Исходный каталог после установки -нельзя перемещать: домашние ссылки зависят от его постоянного пути. +Установщик создаёт три ссылки на исходный каталог: CLI, Bash-модуль и каталог +шаблонов. Поэтому исходный каталог после установки нельзя перемещать. Если +какой-то из путей уже занят чужим файлом или ссылкой, установщик ничего не +меняет и перечисляет все такие пути. Повторный запуск безопасен. -По умолчанию установщик добавляет в `~/.bashrc` помеченный блок для -`~/.local/bin` и Bash-модуля. После установки откройте новый Bash или примените -блок в текущей сессии: +По умолчанию установщик дописывает в `~/.bashrc` блок между строками +`# >>> proxy-client >>>` и `# <<< proxy-client <<<`: он добавляет +`~/.local/bin` в `PATH` и подключает Bash-модуль. Если блок уже есть, файл не +меняется. После установки откройте новый Bash или примените блок в текущей +сессии: ```bash source ~/.bashrc @@ -73,6 +76,12 @@ source ~/.bash_custom Если `~/.local/bin` отсутствует в `PATH` и в новых сессиях, добавьте ту же строку `export` в свой Bash-конфиг: корневой установщик dotfiles его не изменяет. +Проверить пакет после правок (из корня dotfiles): + +```bash +tests/test_proxy_client_full.sh +``` + ## Как выбирается прокси Режим определяется только машинным файлом @@ -253,8 +262,13 @@ proxy-client stop плане. Systemd, фоновый автозапуск и скрытый запуск из `run` не используются. Один loopback-порт принимает HTTP CONNECT и SOCKS5. -`stop` завершает управляемые процессы и удаляет созданные служебные файлы. Он -работает и после изменения машинного типа на `external`. +Остановить Xray можно двумя способами: `Ctrl+C` в окне `start` или +`proxy-client stop` из любого терминала. Оба гасят Xray и удаляют служебные +файлы. Если процесс `start` был убит (например, закрыли окно tmux), Xray может +остаться работать: `status` и `env` тогда сообщат об ошибке, а `stop` добьёт его +и уберёт файлы. `stop` работает и после изменения машинного типа на `external`. + +Второй `start`, пока первый работает, завершится ошибкой. ## Диагностика @@ -343,7 +357,8 @@ source ~/.config/proxy-client/shell.bash Поддерживаются VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные, повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN -`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. +`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом, его содержимое +проверяет `xray run -test`. Кроме `encryption=none`, принимается VLESS-шифрование `encryption=mlkem768x25519plus.…` из новых ссылок Hiddify; остальную часть @@ -351,9 +366,9 @@ source ~/.config/proxy-client/shell.bash `flow=xtls-rprx-vision`. Косая черта перед параметрами (`…:443/?type=…`) допустима, другой путь в ссылке — нет. -Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и -`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но -обычная проверка сертификата остаётся включённой. +Xray 26.3.27 использует `pcs` и `vcn`. Устаревшие `allowInsecure` и `insecure` +принимаются с любым значением, но не переносятся: при значении `1` или `true` +выводится предупреждение, а сертификат проверяется обычным способом. Последняя версия Xray, где `allowInsecure` ещё применяется, — [25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция @@ -366,14 +381,18 @@ Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allow `x86_64`/`amd64` используется `Xray-linux-64.zip`, для `aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`; остальные архитектуры отклоняются. -CLI скачивает официальный архив и файл `.dgst`, проверяет SHA-256, ограничения -размера и фактическую версию бинарника, а затем атомарно заменяет назначение. -Корректная уже установленная версия даёт no-op без сети. Другой файл или симлинк -не заменяется без `--replace`; каталог не заменяется никогда. +CLI скачивает официальный архив и файл `.dgst`, сверяет SHA-256 и фактическую +версию бинарника, а затем атомарно заменяет назначение. При любой ошибке старый +файл остаётся на месте. Если нужная версия уже установлена, сеть не нужна и +ничего не меняется. Другой файл или симлинк не заменяется без `--replace` +(симлинк заменяется сам, файл по ссылке не трогается); каталог не заменяется +никогда. Если после установки `PATH` находит другой `xray`, `install` сообщает об +ошибке и подсказывает команду. + +Устанавливать можно и при работающем Xray: он продолжит работу со старым файлом, +а `install` напомнит перезапустить `start`. `start` использует первый `xray` из `PATH` и принимает только версию 26.3.27. -Если `PATH` выбирает другой бинарник, команда сообщает об ошибке. Во время -живого или неподтверждённого управляемого процесса установка запрещена. ### Файлы и безопасность @@ -385,6 +404,8 @@ CLI скачивает официальный архив и файл `.dgst`, п | `$XDG_STATE_HOME/proxy-client/` или `~/.local/state/proxy-client/` | Состояние и сгенерированный JSON | Сводка скрывает UUID и ключи, а для XHTTP показывает только длину `path`. -Служебный каталог закрыт для других пользователей и очищается после штатной -остановки Xray. Сбой определения внешнего IP показывается как предупреждение и +Если файл профиля доступен другим пользователям, `start` не запустится и +покажет готовую команду `chmod 600` для конечного файла (у симлинка — для файла, +на который он указывает). Служебный каталог закрыт для других пользователей +(`700`, файлы в нём — `600`) и очищается после остановки Xray. Сбой определения внешнего IP показывается как предупреждение и не останавливает уже готовый процесс. diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index d9e1503..d84d8e9 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -import fcntl -import http.client -import json +"""proxy-client без управляемого Xray: команда, машинный конфиг, разбор ссылки, install.""" import hashlib +import json import os import runpy import socket @@ -10,308 +9,92 @@ import subprocess import sys import tempfile import unittest -import urllib.error import zipfile -from unittest import mock 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"] -class FakeUrlResponse: - def __init__(self, data=b"", content_length=None, read_error=None): - self.data = data - self.position = 0 - self.read_error = read_error - self.headers = {} - if content_length is not None: - self.headers["Content-Length"] = str(content_length) - - def __enter__(self): - return self - - def __exit__(self, _type, _value, _traceback): - return False - - def read(self, size=-1): - if self.read_error is not None: - raise self.read_error - if size < 0: - size = len(self.data) - self.position - chunk = self.data[self.position : self.position + size] - self.position += len(chunk) - return chunk +def closed_port(): + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] -def checking_socket_setup(outcome="success"): - 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\n" - "expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n" - "class Connection:\n" - " def close(self):\n" - " pass\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 ProxyClientCliTest(unittest.TestCase): +class ExternalProxyCliTest(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" + 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 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-client" - config_dir.mkdir(exist_ok=True) - (config_dir / "config").write_text( - "[proxy]\n" - "type = {}\n" - "name = {}\n" - "address = {}\n" - "port = {}\n" + 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, name, address, port, no_proxy_extra + proxy_type, address, port or self.port, no_proxy_extra ), encoding="utf-8", ) - def write_raw_config(self, content): - config_dir = self.config_home / "proxy-client" - 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) + def run_cli(self, *arguments, **environment): return subprocess.run( [str(CLI)] + list(arguments), + env=dict(self.environment, **environment), text=True, capture_output=True, - env=command_environment, + timeout=15, 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): + 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.assertFalse((self.config_home / "proxy-client").exists()) + self.assertEqual([], list(self.root.iterdir())) - def test_old_python_version_gets_clear_error_before_main_program(self): + def test_old_python_gets_clear_error(self): code = ( - "import runpy, sys; " - "sys.version_info = (3, 7, 9); " + "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, + [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_proc_stat_parser_handles_spaces_and_closing_parentheses_in_comm(self): - module = runpy.run_path(str(CLI)) - parse_starttime = module["parse_proc_stat_starttime"] - tail = ["S"] + [str(value) for value in range(4, 22)] + ["987654"] + 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) - for comm in ("worker", "worker name", "worker name ) with parens"): - with self.subTest(comm=comm): - self.assertEqual( - 987654, - parse_starttime("321 ({}) ".format(comm) + " ".join(tail)), - ) - for malformed in ("321 worker S 1 2", "321 (worker) S 1 2"): - with self.subTest(malformed=malformed): - with self.assertRaises(ValueError): - parse_starttime(malformed) - - def test_process_permission_error_is_not_treated_as_dead(self): - module = runpy.run_path(str(CLI)) - with mock.patch.object(module["Path"], "stat", side_effect=PermissionError()): - self.assertTrue(module["process_alive"](12345)) - - def test_same_pid_cannot_be_a_healthy_managed_pair(self): - module = runpy.run_path(str(CLI)) - function_globals = module["managed_state_outcome"].__globals__ - state = {"supervisor_pid": 12345, "xray_pid": 12345, "boot_id": "boot"} - replacements = { - "read_boot_id": mock.Mock(return_value="boot"), - "process_alive": mock.Mock(return_value=True), - "supervisor_identity_matches": mock.Mock(return_value=True), - "xray_identity_matches": mock.Mock(return_value=True), - } - with mock.patch.dict(function_globals, replacements): - self.assertEqual("unverifiable", module["managed_state_outcome"](state)) - - def test_identity_reads_starttime_after_process_paths(self): - module = runpy.run_path(str(CLI)) - function_globals = module["xray_identity_matches"].__globals__ - state = { - "supervisor_pid": 101, - "supervisor_starttime": 201, - "supervisor_path": "/cli", - "xray_pid": 102, - "xray_starttime": 202, - "xray_path": "/xray", - } - - xray_events = [] - xray_replacements = { - "process_executable": lambda _pid: xray_events.append("exe") or "/xray", - "process_starttime": lambda _pid: xray_events.append("stat") or 202, - } - with mock.patch.dict(function_globals, xray_replacements): - self.assertTrue(module["xray_identity_matches"](state)) - self.assertEqual(["exe", "stat"], xray_events) - - supervisor_events = [] - supervisor_replacements = { - "process_cmdline": lambda _pid: supervisor_events.append("cmdline") - or ["python", "/cli"], - "process_argument_resolves_to": lambda *_args: supervisor_events.append( - "script" - ) - or True, - "process_starttime": lambda _pid: supervisor_events.append("stat") or 201, - } - with mock.patch.dict(function_globals, supervisor_replacements): - self.assertTrue(module["supervisor_identity_matches"](state)) - self.assertEqual(["cmdline", "script", "stat"], supervisor_events) - - def test_initial_child_starttime_retries_while_child_is_alive(self): - module = runpy.run_path(str(CLI)) - wait_for_starttime = module["wait_for_child_starttime"] - function_globals = wait_for_starttime.__globals__ - child = mock.Mock(pid=12345) - child.poll.return_value = None - replacements = { - "process_starttime": mock.Mock(side_effect=[None, 7654321]), - "time": mock.Mock(), - } - with mock.patch.dict(function_globals, replacements): - self.assertEqual(7654321, wait_for_starttime(child)) - - def test_broken_config_symlink_is_not_treated_as_missing_config(self): - config_dir = self.config_home / "proxy-client" - 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) + 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, @@ -323,991 +106,464 @@ class ProxyClientCliTest(unittest.TestCase): "all_proxy=" + socks_url, "no_proxy=" + no_proxy, ], - result.stdout.splitlines(), + env.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), + 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)) - 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_scoped_ipv6_is_rejected_when_ipaddress_lacks_scope_support(self): - self.write_config(23456, address="fe80::1%eth0", no_proxy_extra="") - setup = ( - "import ipaddress\n" - "real_ip_address = ipaddress.ip_address\n" - "def python38_ip_address(value):\n" - " if '%' in value:\n" - " raise ValueError('scope IDs unsupported')\n" - " return real_ip_address(value)\n" - "ipaddress.ip_address = python38_ip_address\n" - + SUCCESSFUL_SOCKET_SETUP - ) - result = self.run_cli_with_socket_setup( - ("env",), - setup, - expected_connection=("fe80::1%eth0", 23456, 2.0), + killed = self.run_cli( + "run", "--", sys.executable, "-c", + "import os, signal; os.kill(os.getpid(), signal.SIGTERM)", ) + self.assertEqual(143, killed.returncode, killed.stderr) - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("address", 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) + 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_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: + 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.assertNotEqual(0, result.returncode) + self.assertEqual(1, result.returncode) self.assertEqual("", result.stdout) - self.assertIn("Ошибка:", result.stderr) - self.assertFalse(marker.exists()) + self.assertIn("не запущен", result.stderr) + self.assertFalse(marker.exists()) - def test_external_start_fails_without_starting_anything(self): - self.write_config(12334) + def test_start_refuses_external_type(self): + self.write_config() result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) + self.assertEqual(1, result.returncode) 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), - ) + 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) - 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_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_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) + 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): - result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) - self.assertIn("run -- COMMAND", result.stderr) + self.assertIn("run -- COMMAND", self.run_cli(*command).stderr) - def test_invalid_preflight_timeout_is_rejected_clearly(self): - self.write_config(12334) - result = self.run_cli( - "status", environment={"PROXY_PREFLIGHT_TIMEOUT": "not-a-number"} - ) - self.assertNotEqual(0, result.returncode) + 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) - 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" + +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), ) - invalid_configs = { - "unknown section": valid + "[other]\nkey = value\n", - "unknown key": valid + "extra = value\n", - "duplicate key": valid + "port = 12335\n", - "missing key": valid.replace("name = Test\n", ""), - "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( + + 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, 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) + 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_address_accepts_ip_literals_and_simple_ascii_names(self): - valid_addresses = ( - "127.0.0.1", - "2001:db8::1", - "proxy-1.example", - ".leading.example", - "trailing.example.", - "bad..example", - "-bad.example", - "bad-.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) + 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)) - invalid_addresses = ( - "http://proxy.example", - "[::1]", - "has space", - "proxy/path", - "user@proxy", - "прокси.example", - "fe80::1%eth0", + 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"], ) - 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) + + 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): - self.temporary = tempfile.TemporaryDirectory() - self.root = Path(self.temporary.name) + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) self.home = self.root / "home" - self.state_home = self.root / "state" self.home.mkdir() + self.destination = self.home / ".local/bin/xray" self.environment = { "HOME": str(self.home), - "XDG_STATE_HOME": str(self.state_home), - "PATH": str(self.home / ".local/bin"), + "XDG_STATE_HOME": str(self.root / "state"), + "PATH": str(self.destination.parent), } - self.module = runpy.run_path(str(CLI)) - self.destination = self.home / ".local/bin/xray" - self.archive = self.root / "fixture.zip" - self.write_archive("installed") - - def tearDown(self): - self.temporary.cleanup() + self.archive = self.make_archive(self.fake_xray("installed")) @staticmethod - def fake_xray(label="fixture", version="26.3.27"): - return ( - "#!/bin/sh\n" - "if [ \"$1\" = version ]; then\n" - " printf '%s\\n' 'Xray {} ({})'\n" - " exit 0\n" - "fi\n" - "exit 64\n".format(version, label) + def fake_xray(label, version="26.3.27"): + return "#!/bin/sh\n[ \"$1\" = version ] && echo 'Xray {} ({})'\n".format( + version, label ).encode("ascii") - def write_archive(self, label="fixture", version="26.3.27", member="xray"): - with zipfile.ZipFile(str(self.archive), "w") as archive: - archive.writestr(member, self.fake_xray(label, version)) + 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() - @staticmethod - def official_digest(archive_hash): - return ( - "MD5= {}\n" - "SHA1= {}\n" - "SHA2-256= {}\n" - "SHA2-512= {}\n".format( - "1" * 32, - "2" * 40, - archive_hash, - "3" * 128, + 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() + ) ) - ) - - def downloader(self, bad_hash=False, digest_text=None, fail_after_archive=False): calls = [] - def download(url, destination, _max_bytes): + def download(url, destination): calls.append(url) if url.endswith(".dgst"): - if fail_after_archive: - raise self.module["ProxyError"]("инъецированная ошибка загрузки") - archive_hash = hashlib.sha256(self.archive.read_bytes()).hexdigest() - if bad_hash: - archive_hash = "0" * 64 - text = digest_text - if text is None: - text = self.official_digest(archive_hash) - destination.write_text(text, encoding="ascii") + destination.write_text(digest, encoding="ascii") else: - destination.write_bytes(self.archive.read_bytes()) + destination.write_bytes(archive) return download, calls def install(self, replace=False, downloader=None, machine="x86_64", path=None): if downloader is None: - downloader, _calls = self.downloader() + downloader = self.downloader()[0] environment = dict(self.environment) if path is not None: environment["PATH"] = path - globals_ = self.module["command_install"].__globals__ - with mock.patch.dict(os.environ, environment, clear=False): - with mock.patch.object(globals_["platform"], "machine", return_value=machine): - return self.module["command_install"](replace, downloader) + 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=None, executable=True): + def write_destination(self, content=b"old sentinel", executable=True): self.destination.parent.mkdir(parents=True, exist_ok=True) - self.destination.write_bytes(content or self.fake_xray("existing")) + self.destination.write_bytes(content) self.destination.chmod(0o755 if executable else 0o644) - def tree_snapshot(self): - snapshot = {} - for path in self.root.rglob("*"): - relative = path.relative_to(self.root).as_posix() - info = path.lstat() - mode = info.st_mode & 0o777 - if path.is_symlink(): - snapshot[relative] = ("symlink", mode, os.readlink(str(path))) - elif path.is_dir(): - snapshot[relative] = ("directory", mode) - elif path.is_file(): - digest = hashlib.sha256(path.read_bytes()).hexdigest() - snapshot[relative] = ("file", mode, digest) - else: - snapshot[relative] = ("other", mode) - return snapshot + 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 tree_snapshot_without_install_lock(self): - snapshot = self.tree_snapshot() - for path in ("state/proxy-client/lock", "state/proxy-client", "state"): - snapshot.pop(path, None) - return snapshot - - def assert_no_staging(self): - if self.destination.parent.exists(): - self.assertEqual( - [], list(self.destination.parent.glob(".xray-install-*")) - ) - - def assert_install_lock_released(self): - lock_path = self.state_home / "proxy-client/lock" - with lock_path.open("r+", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def test_architecture_mapping_is_pure_and_rejects_unknown(self): - asset_for = self.module["xray_asset_for_architecture"] - for machine in ("x86_64", "amd64"): - self.assertEqual("Xray-linux-64.zip", asset_for(machine)) - for machine in ("aarch64", "arm64"): - self.assertEqual("Xray-linux-arm64-v8a.zip", asset_for(machine)) - with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): - asset_for("riscv64") - - def test_valid_digest_installs_verified_executable_and_only_expected_files(self): + def test_fresh_install(self): downloader, calls = self.downloader() - self.assertEqual(0, self.install(downloader=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.assertEqual(2, len(calls)) - self.assertTrue(calls[0].endswith("/Xray-linux-64.zip")) - self.assertEqual(calls[0] + ".dgst", calls[1]) - self.assertEqual( - { - "fixture.zip", - "home", - "home/.local", - "home/.local/bin", - "home/.local/bin/xray", - "state", - "state/proxy-client", - "state/proxy-client/lock", - }, - set(self.tree_snapshot()), - ) - self.assert_no_staging() + 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_platform_machine_selects_arm64_asset(self): + def test_architecture_selects_asset(self): downloader, calls = self.downloader() - self.assertEqual( - 0, self.install(downloader=downloader, machine="aarch64") - ) + self.install(downloader=downloader, machine="aarch64") self.assertTrue(calls[0].endswith("/Xray-linux-arm64-v8a.zip")) - self.assertEqual(calls[0] + ".dgst", calls[1]) + with self.assertRaisesRegex(ProxyError, "не поддерживается"): + self.install(replace=True, downloader=self.fail_download, machine="riscv64") - def test_candidate_is_private_while_zip_bytes_are_written(self): - os_module = self.module["extract_xray_candidate"].__globals__["os"] - real_fdopen = os_module.fdopen - observed_modes = [] + @staticmethod + def fail_download(_url, _destination): + raise AssertionError("сеть не должна использоваться") - def checked_fdopen(descriptor, *arguments, **keywords): - descriptor_path = os.readlink("/proc/self/fd/{}".format(descriptor)) - if Path(descriptor_path).name == "xray": - observed_modes.append(os.fstat(descriptor).st_mode & 0o777) - return real_fdopen(descriptor, *arguments, **keywords) + 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()) - with mock.patch.object( - os_module, "fdopen", side_effect=checked_fdopen - ): - self.assertEqual(0, self.install()) + 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], + } - self.assertEqual([0o600], observed_modes) - self.assertEqual(0o755, self.destination.stat().st_mode & 0o777) + def interrupted(_url, destination): + destination.write_bytes(b"partial") + raise KeyboardInterrupt - def test_bad_hash_and_digest_format_preserve_destination_before_extraction(self): - valid_hash = "a" * 64 - cases = ( - self.downloader(bad_hash=True)[0], - self.downloader(digest_text="SHA256= " + valid_hash + "\n")[0], - self.downloader( - digest_text=self.official_digest(valid_hash).replace( - "SHA2-256= " + valid_hash + "\n", "" - ) - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash) - + "SHA2-256= " - + valid_hash - + "\n" - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash) + "garbage\n" - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash).replace( - valid_hash, "a" * 63 - ) - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash).replace( - valid_hash, "a" * 65 - ) - )[0], - ) - for downloader in cases: - with self.subTest(downloader=downloader): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with self.assertRaises(self.module["ProxyError"]): + for label, downloader in cases.items(): + with self.subTest(label): + self.write_destination() + with self.assertRaises(ProxyError): self.install(replace=True, downloader=downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) + self.assert_destination_kept() + with self.assertRaises(KeyboardInterrupt): + self.install(replace=True, downloader=interrupted) + self.assert_destination_kept() - def test_bad_or_truncated_archive_is_rejected_before_zip_open(self): - full_archive = self.archive.read_bytes() + 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 truncated_downloader(url, destination, _max_bytes): - if url.endswith(".dgst"): - digest = hashlib.sha256(full_archive).hexdigest() - destination.write_text(self.official_digest(digest), encoding="ascii") - else: - destination.write_bytes(full_archive[: len(full_archive) // 2]) + 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()) - zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] - with mock.patch.object( - zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до SHA-256") - ) as opened: - with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): - self.install(downloader=truncated_downloader) - opened.assert_not_called() - self.assertFalse(self.destination.exists()) - self.assert_no_staging() - - def test_real_downloader_installs_complete_declared_responses(self): - archive = self.archive.read_bytes() - digest = self.official_digest(hashlib.sha256(archive).hexdigest()).encode( - "ascii" - ) - responses = [ - FakeUrlResponse(archive, content_length=len(archive)), - FakeUrlResponse(digest, content_length=len(digest)), - ] - urllib_request = self.module["download_file"].__globals__["urllib"].request - - with mock.patch.object(urllib_request, "urlopen", side_effect=responses): - self.assertEqual( - 0, self.install(downloader=self.module["download_file"]) - ) - - def test_real_downloader_refuses_length_errors_and_releases_install(self): - archive = self.archive.read_bytes() - cases = ( - ( - "early EOF", - FakeUrlResponse(archive, content_length=len(archive) + 1), - ), - ( - "extra bytes", - FakeUrlResponse(archive, content_length=len(archive) - 1), - ), - ( - "declared oversized", - FakeUrlResponse( - b"", - content_length=self.module["MAX_ARCHIVE_BYTES"] + 1, - ), - ), - ("url error", urllib.error.URLError("offline")), - ( - "http error", - FakeUrlResponse(read_error=http.client.HTTPException("broken HTTP")), - ), - ( - "incomplete read", - FakeUrlResponse( - read_error=http.client.IncompleteRead(b"partial", 10) - ), - ), - ) - urllib_request = self.module["download_file"].__globals__["urllib"].request - - for label, outcome in cases: - with self.subTest(label=label): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with mock.patch.object(urllib_request, "urlopen", side_effect=[outcome]): - with self.assertRaises(self.module["ProxyError"]): - self.install( - replace=True, downloader=self.module["download_file"] - ) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assert_install_lock_released() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_real_downloader_counts_limit_plus_one_without_content_length(self): - urllib_request = self.module["download_file"].__globals__["urllib"].request - globals_ = self.module["command_install"].__globals__ - sentinel = b"destination sentinel" - self.write_destination(sentinel) - - with mock.patch.dict(globals_, {"MAX_ARCHIVE_BYTES": 8}): - response = FakeUrlResponse(b"123456789") - with mock.patch.object(urllib_request, "urlopen", return_value=response): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install( - replace=True, downloader=self.module["download_file"] - ) - - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assert_install_lock_released() - - def test_uppercase_digest_is_rejected_before_zip_open(self): - digest = hashlib.sha256(self.archive.read_bytes()).hexdigest().upper() - downloader, _calls = self.downloader( - digest_text=self.official_digest(digest) - ) - zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] - with mock.patch.object( - zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до digest") - ) as opened: - with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): - self.install(downloader=downloader) - opened.assert_not_called() - self.assertFalse(self.destination.exists()) - self.assert_no_staging() - - def test_existing_verified_install_is_noop_without_downloader(self): - self.write_destination() - downloader = mock.Mock(side_effect=AssertionError("network used")) - self.assertEqual(0, self.install(downloader=downloader)) - downloader.assert_not_called() - - def test_existing_version_gate_keeps_timeout_and_avoids_downloader(self): - self.write_destination() - downloader = mock.Mock(side_effect=AssertionError("network used")) - subprocess_module = self.module["xray_version"].__globals__["subprocess"] - timeout = subprocess.TimeoutExpired([str(self.destination), "version"], 5) - - with mock.patch.object(subprocess_module, "run", side_effect=timeout) as run: - with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): - self.install(downloader=downloader) - - downloader.assert_not_called() - self.assertEqual(5, run.call_args.kwargs["timeout"]) - self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) - - def test_replace_reinstalls_verified_existing_file(self): - self.write_destination(self.fake_xray("old")) - downloader, calls = self.downloader() - self.assertEqual(0, self.install(replace=True, downloader=downloader)) - self.assertIn(b"installed", self.destination.read_bytes()) - self.assertEqual(2, len(calls)) - - def test_destination_table_for_symlink_directory_and_bad_files(self): - self.destination.parent.mkdir(parents=True) + 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(self.module["ProxyError"], "--replace"): - self.install() - self.assertTrue(self.destination.is_symlink()) - self.assertEqual(0, self.install(replace=True)) + 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()) - self.destination.unlink() - self.destination.mkdir() + def test_directory_destination_is_refused(self): + self.destination.mkdir(parents=True) for replace in (False, True): - with self.subTest(directory=True, replace=replace): - with self.assertRaisesRegex(self.module["ProxyError"], "каталог"): - self.install(replace=replace) - self.destination.rmdir() + with self.subTest(replace=replace): + with self.assertRaisesRegex(ProxyError, "каталог"): + self.install(replace=replace, downloader=self.fail_download) - for content, executable in ((b"broken", True), (self.fake_xray(), False)): - with self.subTest(content=content, executable=executable): - self.write_destination(content, executable) - with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): - self.install() - self.assertEqual(0, self.install(replace=True)) - self.assertIn(b"installed", self.destination.read_bytes()) - self.destination.unlink() - - def test_parent_symlink_cannot_redirect_install_outside_home(self): - outside = self.root / "outside" - outside.mkdir() - (self.home / ".local").symlink_to(outside, target_is_directory=True) - downloader = mock.Mock() - - with self.assertRaisesRegex(self.module["ProxyError"], "домашнего каталога"): - self.install(downloader=downloader) - - downloader.assert_not_called() - self.assertEqual([], list(outside.iterdir())) - - def test_managed_state_guard_only_allows_absent_or_stale(self): - guard = self.module["require_install_safe_managed_state"] - globals_ = guard.__globals__ - present = mock.Mock() - present.lstat.return_value = mock.Mock() - with mock.patch.dict( - globals_, - { - "state_path": mock.Mock(return_value=present), - "read_managed_state": mock.Mock(return_value={"state": "fixture"}), - }, - ): - for outcome in ("healthy", "orphan", "unverifiable"): - with self.subTest(outcome=outcome): - globals_["managed_state_outcome"] = mock.Mock(return_value=outcome) - with self.assertRaisesRegex(self.module["ProxyError"], "stop"): - guard() - globals_["managed_state_outcome"] = mock.Mock(return_value="stale") - guard() - - absent = mock.Mock() - absent.lstat.side_effect = FileNotFoundError() - with mock.patch.dict( - globals_, {"state_path": mock.Mock(return_value=absent)} - ): - guard() - - def test_command_classifies_managed_state_before_even_noop_destination(self): - self.write_destination() - downloader = mock.Mock(side_effect=AssertionError("network used")) - guard = mock.Mock( - side_effect=self.module["ProxyError"]( - "managed Xray работает; сначала выполните proxy-client stop" - ) - ) - globals_ = self.module["command_install"].__globals__ - - with mock.patch.dict( - globals_, {"require_install_safe_managed_state": guard} - ): - with self.assertRaisesRegex(self.module["ProxyError"], "stop"): - self.install(downloader=downloader) - - guard.assert_called_once_with() - downloader.assert_not_called() - self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) - - def test_download_extraction_and_version_errors_are_atomic_and_clean_staging(self): - failures = [] - failures.append(self.downloader(fail_after_archive=True)[0]) - missing_member = self.root / "missing-member.zip" - with zipfile.ZipFile(str(missing_member), "w") as archive: - archive.writestr("not-xray", b"data") - - def missing_member_downloader(url, destination, _max_bytes): - source = missing_member if not url.endswith(".dgst") else None - if source is not None: - destination.write_bytes(source.read_bytes()) - else: - digest = hashlib.sha256(missing_member.read_bytes()).hexdigest() - destination.write_text(self.official_digest(digest), encoding="ascii") - - failures.append(missing_member_downloader) - self.write_archive("wrong", version="26.3.26") - failures.append(self.downloader()[0]) - - for downloader in failures: - with self.subTest(downloader=downloader): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with self.assertRaises(self.module["ProxyError"]): - self.install(replace=True, downloader=downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_corrupt_zip_with_matching_digest_is_atomic_and_cleaned(self): - corrupt = b"not a ZIP archive" - - def corrupt_downloader(url, destination, _max_bytes): - if url.endswith(".dgst"): - digest = hashlib.sha256(corrupt).hexdigest() - destination.write_text(self.official_digest(digest), encoding="ascii") - else: - destination.write_bytes(corrupt) - - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with self.assertRaisesRegex(self.module["ProxyError"], "извлечь"): - self.install(replace=True, downloader=corrupt_downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_injected_oversized_archive_and_digest_are_bounded(self): - globals_ = self.module["command_install"].__globals__ - cases = ( - ("MAX_ARCHIVE_BYTES", len(self.archive.read_bytes()) - 1, self.downloader()[0]), - ( - "MAX_DIGEST_BYTES", - 8, - self.downloader(digest_text=self.official_digest("a" * 64))[0], - ), - ) - for constant, limit, downloader in cases: - with self.subTest(constant=constant): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - with mock.patch.dict(globals_, {constant: limit}): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install(replace=True, downloader=downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - - def test_zip_member_declared_over_limit_is_rejected_before_extraction(self): - globals_ = self.module["command_install"].__globals__ - binary_size = len(self.fake_xray()) - sentinel = b"destination sentinel" - self.write_destination(sentinel) - - with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": binary_size - 1}): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install(replace=True) - - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - - def test_streamed_zip_output_cannot_exceed_limit_even_if_metadata_lies(self): - class FakeInfo: - filename = "xray" - file_size = 8 - - @staticmethod - def is_dir(): - return False - - class FakeArchive: - def __enter__(self): - return self - - def __exit__(self, _type, _value, _traceback): - return False - - @staticmethod - def infolist(): - return [FakeInfo()] - - @staticmethod - def open(_member): - return FakeUrlResponse(b"123456789") - - globals_ = self.module["command_install"].__globals__ - zip_file = globals_["zipfile"] - sentinel = b"destination sentinel" - self.write_destination(sentinel) - with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": 8}): - with mock.patch.object(zip_file, "ZipFile", return_value=FakeArchive()): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install(replace=True) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - - def test_interruption_preserves_destination_cleans_staging_and_releases_lock(self): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - - def interrupted_downloader(_url, destination, _max_bytes): - destination.write_bytes(b"partial") - raise KeyboardInterrupt() - - with self.assertRaises(KeyboardInterrupt): - self.install(replace=True, downloader=interrupted_downloader) - - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - lock_path = self.state_home / "proxy-client/lock" - with lock_path.open("r+", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_start_lock_is_held_during_download_and_through_replace(self): - downloader, _calls = self.downloader() - lock_path = self.state_home / "proxy-client/lock" - - def assert_lock_held(): - with lock_path.open("r+", encoding="ascii") as competing: - with self.assertRaises(BlockingIOError): - fcntl.flock(competing, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def checked_downloader(url, destination, max_bytes): - assert_lock_held() - downloader(url, destination, max_bytes) - - os_module = self.module["command_install"].__globals__["os"] - real_replace = os_module.replace - - def checked_replace(source, destination): - real_replace(source, destination) - assert_lock_held() - - with mock.patch.object(os_module, "replace", side_effect=checked_replace): - self.assertEqual(0, self.install(downloader=checked_downloader)) - - with lock_path.open("r+", encoding="ascii") as released: - fcntl.flock(released, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def test_unsupported_architecture_refuses_before_downloading(self): - downloader = mock.Mock() - with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): - self.install(downloader=downloader, machine="riscv64") - downloader.assert_not_called() - self.assertFalse(self.destination.exists()) - - def test_path_missing_or_competing_returns_error_without_rollback(self): - cases = (("empty", False), ("competitor", True)) - for directory_name, competing in cases: - with self.subTest(directory_name=directory_name): - path_dir = self.root / directory_name - path_dir.mkdir() - if competing: - competitor = path_dir / "xray" - competitor.write_bytes(self.fake_xray("competitor")) - competitor.chmod(0o755) - path = str(path_dir) + os.pathsep + str(self.destination.parent) - else: - path = str(path_dir) - with self.assertRaisesRegex(self.module["ProxyError"], "PATH"): + 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_busy_install_hint_uses_exception_type_not_message_text(self): - globals_ = self.module["acquire_install_lock"].__globals__ - busy_error = self.module["StartLockBusyError"]("completely changed text") - with mock.patch.dict( - globals_, {"acquire_start_lock": mock.Mock(side_effect=busy_error)} - ): - with self.assertRaisesRegex(self.module["ProxyError"], "stop"): - self.module["acquire_install_lock"]() + 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_path_comparison_handles_relative_entry_and_internal_bin_symlink(self): - local = self.home / ".local" - actual_bin = self.home / "private-bin" - local.mkdir() - actual_bin.mkdir() - (local / "bin").symlink_to(actual_bin, target_is_directory=True) - - self.assertEqual(0, self.install()) - self.assertTrue((actual_bin / "xray").is_file()) - - previous_cwd = Path.cwd() - try: - os.chdir(str(actual_bin)) - self.assertEqual(0, self.install(path=".")) - finally: - os.chdir(str(previous_cwd)) - - def test_install_dispatches_before_machine_config_loading(self): - self.write_destination() + 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 = os.environ.copy() + environment = dict(os.environ, XDG_CONFIG_HOME=str(self.root / "config")) environment.update(self.environment) - environment["PATH"] = self.environment["PATH"] + os.pathsep + os.environ["PATH"] - environment["XDG_CONFIG_HOME"] = str(self.root / "config") + environment["PATH"] += os.pathsep + os.environ["PATH"] result = subprocess.run( - [str(CLI), "install"], - text=True, - capture_output=True, - env=environment, - check=False, + [str(CLI), "install"], env=environment, text=True, capture_output=True, check=False ) self.assertEqual(0, result.returncode, result.stderr) self.assertIn("уже установлен", result.stdout) -class VlessProfileTest(unittest.TestCase): - UUID = "11111111-2222-4333-8444-555555555555" - ENCRYPTION = "mlkem768x25519plus.native.0rtt.SYNTHETIC_CLIENT_KEY" - EXTRA = ( - "%7B%22downloadSettings%22%3A%7B%22address%22%3A%22cdn.example.invalid%22" - "%2C%22port%22%3A443%2C%22network%22%3A%22xhttp%22%7D%7D" - ) - OLD_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#old" - ).format(UUID) - OLD_XHTTP = ( - "vless://{}@example.invalid:443?type=xhttp&security=tls&encryption=none" - "&sni=cdn.example.invalid&fp=chrome&host=cdn.example.invalid&path=%2Fp" - "&alpn=h2&mode=auto" - ).format(UUID) - # Формат Hiddify с VLESS-шифрованием: «/» перед «?» и flow поверх XHTTP. - NEW_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, EXTRA, ENCRYPTION) - - def setUp(self): - self.module = runpy.run_path(str(CLI)) - - def outbound(self, url): - profile = self.module["parse_vless"](url) - config = self.module["build_xray_config"](profile, "127.0.0.1", 12334) - return config["outbounds"][0] - - def test_old_formats_still_parse(self): - reality = self.outbound(self.OLD_REALITY) - self.assertEqual("none", reality["settings"]["encryption"]) - self.assertEqual("reality", reality["streamSettings"]["security"]) - - xhttp = self.outbound(self.OLD_XHTTP) - self.assertEqual("none", xhttp["settings"]["encryption"]) - self.assertNotIn("flow", xhttp["settings"]) - self.assertEqual(["h2"], xhttp["streamSettings"]["tlsSettings"]["alpn"]) - - def test_new_hiddify_xhttp_with_vless_encryption_parses(self): - outbound = self.outbound(self.NEW_XHTTP) - settings = outbound["settings"] - stream = outbound["streamSettings"] - self.assertEqual(self.ENCRYPTION, settings["encryption"]) - self.assertEqual("xtls-rprx-vision", settings["flow"]) - self.assertEqual("192.0.2.1", settings["address"]) - self.assertEqual(["h3"], stream["tlsSettings"]["alpn"]) - self.assertEqual( - "cdn.example.invalid", - stream["xhttpSettings"]["extra"]["downloadSettings"]["address"], - ) - - def test_incompatible_profiles_are_still_rejected(self): - cases = { - "flow over XHTTP without encryption": self.OLD_XHTTP - + "&flow=xtls-rprx-vision", - "unknown encryption": self.NEW_XHTTP.replace( - "mlkem768x25519plus.", "aes-128-gcm." - ), - "non-root path": self.NEW_XHTTP.replace(":443/?", ":443/sub?"), - } - for label, url in cases.items(): - with self.subTest(label=label): - with self.assertRaises(self.module["ProxyError"]): - self.module["parse_vless"](url) - - class PortCheckTest(unittest.TestCase): - def setUp(self): - self.module = runpy.run_path(str(CLI)) - self.check = self.module["check_port_available"] - self.address = self.module["ipaddress"].ip_address("127.0.0.1") + address = MODULE["ipaddress"].ip_address("127.0.0.1") @staticmethod def listener(): @@ -1328,17 +584,14 @@ class PortCheckTest(unittest.TestCase): accepted.close() listener.close() try: - self.check(self.address, port) + MODULE["check_port_available"](self.address, port) finally: client.close() - def test_live_listener_is_still_reported_as_busy(self): - listener = self.listener() - try: - with self.assertRaisesRegex(self.module["ProxyError"], "занят"): - self.check(self.address, listener.getsockname()[1]) - finally: - listener.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__": diff --git a/tests/test_proxy_client_managed.py b/tests/test_proxy_client_managed.py index fae2ce8..b8fabe9 100644 --- a/tests/test_proxy_client_managed.py +++ b/tests/test_proxy_client_managed.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 -import fcntl +"""Команда proxy-client с управляемым Xray: argv → код выхода, вывод, файлы, процессы.""" import json import os -import shutil import signal import socket import subprocess import sys import tempfile -import time +import threading import unittest from pathlib import Path from urllib.parse import quote @@ -17,1117 +16,281 @@ from urllib.parse import quote REPO_ROOT = Path(__file__).resolve().parents[1] CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" 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 +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="")) +READY = "остановка: Ctrl+C или proxy-client stop" - -FAKE_XRAY_OPERAND = r'''import json -import os -import re -import signal -import socket -import sys -import threading - -log_path = os.environ["FAKE_XRAY_LOG"] -operation = os.path.basename(sys.argv[0]) - -def record(kind, **values): - values["kind"] = kind - values["argv"] = [operation] + sys.argv[1:] - values["executable"] = os.path.realpath("/proc/self/exe") - with open(log_path, "a", encoding="utf-8") as stream: - stream.write(json.dumps(values, sort_keys=True) + "\n") - -if operation == "version" and not sys.argv[1:]: - record("version") - print(os.environ["FAKE_XRAY_VERSION"]) - raise SystemExit(int(os.environ.get("FAKE_VERSION_EXIT", "0"))) - -is_test = operation == "run" and len(sys.argv) == 4 and sys.argv[1:3] == ["-test", "-c"] -is_run = operation == "run" and len(sys.argv) == 3 and sys.argv[1] == "-c" -if not (is_test or is_run): - record("bad-argv") - raise SystemExit(64) +# Поддельный Xray — симлинк на интерпретатор Python: тогда /proc/PID/exe +# совпадает с разрешённым путём к xray, как у настоящего бинарника. +# «xray version» и «xray run ...» запускают одноимённые скрипты из рабочего +# каталога теста. +FAKE_VERSION = "print('Xray 26.3.27 (fake)')\n" +FAKE_RUN = r'''import json, os, socket, sys config_path = sys.argv[-1] +if sys.argv[1] == "-test": + if os.environ.get("FAKE_TEST_FAIL"): + # Настоящий Xray может напечатать фрагменты конфига — CLI их глушит. + print(open(config_path).read()) + sys.exit(1) + sys.exit(0) -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 is_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] +inbound = json.load(open(config_path))["inbounds"][0] server = socket.socket() server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((inbound["listen"], inbound["port"])) server.listen(16) -server.settimeout(0.1) -stopping = False - -def stop(signum, _frame): - global stopping - record("signal", signum=signum) - if signum == signal.SIGTERM and os.environ.get("FAKE_IGNORE_TERM") == "1": - return - 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" +while True: + client, _ = server.accept() + request = client.recv(4096) + if b"ifconfig.me" in request and not os.environ.get("FAKE_IP_FAIL"): 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 + b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\n" + b"203.0.113.77\n" ) - 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() + client.close() ''' -class ManagedXrayCliTest(unittest.TestCase): +def free_port(): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def pid_alive(pid): + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + return True + + +class ManagedXrayTest(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" - python_prefix = Path(sys.executable).resolve().parent.parent - python_lib = python_prefix / "lib" - if python_lib.is_dir(): - (self.root / "lib").symlink_to(python_lib, target_is_directory=True) - shutil.copy2(sys.executable, self.xray) - self.xray.chmod(0o755) - for operand in ("version", "run"): - (self.root / operand).write_text(FAKE_XRAY_OPERAND, encoding="utf-8") - 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", - "PROXY_CLIENT_STOP_TIMEOUT": "2", - } + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + bin_dir = self.root / "bin" + bin_dir.mkdir() + self.xray = bin_dir / "xray" + self.xray.symlink_to(os.path.realpath(sys.executable)) + (self.root / "version").write_text(FAKE_VERSION, encoding="utf-8") + (self.root / "run").write_text(FAKE_RUN, encoding="utf-8") + self.config_dir = self.root / "config/proxy-client" + self.state_dir = self.root / "state/proxy-client" + self.environment = dict( + os.environ, + HOME=str(self.root / "home"), + XDG_CONFIG_HOME=str(self.root / "config"), + XDG_STATE_HOME=str(self.root / "state"), + PATH=str(bin_dir) + os.pathsep + os.environ["PATH"], + PYTHONDONTWRITEBYTECODE="1", ) - self.port = self.free_port() - self.write_machine_config("xray") + self.port = free_port() + self.write_machine_config() + self.profile = self.write_profile(self.config_dir / "default.url") - 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-client" - 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), + def write_machine_config(self, address="127.0.0.1"): + self.config_dir.mkdir(parents=True, exist_ok=True) + (self.config_dir / "config").write_text( + "[proxy]\ntype = xray\nname = Managed test\naddress = {}\n" + "port = {}\nno_proxy_extra =\n".format(address, self.port), encoding="utf-8", ) - def write_profile(self, url, explicit=False): - if explicit: - path = self.root / "chosen.url" - else: - path = self.config_home / "proxy-client/default.url" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(url + "\n", encoding="utf-8") - path.chmod(0o600) + def write_profile(self, path, mode=0o600): + path.write_text(PROFILE + "\n", encoding="utf-8") + path.chmod(mode) 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=""): - 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 - ) - 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) + def run_cli(self, *arguments, **environment): return subprocess.run( - [str(CLI)] + list(args), + [str(CLI)] + list(arguments), cwd=str(self.root), + env=dict(self.environment, **environment), text=True, capture_output=True, - env=env, - timeout=timeout, + timeout=15, check=False, ) - def wait_for_state(self, process): - state = self.state_home / "proxy-client/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 managed_state_path(self): - return self.state_home / "proxy-client/state.json" - - def read_state(self): - return json.loads(self.managed_state_path().read_text(encoding="utf-8")) - - def write_state(self, state): - self.managed_state_path().write_text( - json.dumps(state) + "\n", encoding="utf-8" - ) - self.managed_state_path().chmod(0o600) - - def start(self, profile=None): - args = [str(CLI), "start"] - if profile is not None: - args.extend(["-f", str(profile)]) - process = subprocess.Popen( - args, - cwd=str(self.root), - 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_profile_locations(self): - profiles = ((self.xhttp_profile(), False), (self.tcp_profile(), True)) - for profile_text, explicit in profiles: - with self.subTest(explicit=explicit): - profile = self.write_profile(profile_text, explicit=explicit) - process = self.start(profile if explicit else None) - - 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) - version_summary = process.stdout.readline() - self.assertIn(str(self.xray.resolve()), version_summary) - self.assertIn("26.3.27", version_summary) - - 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 / "proxy-client/state.json").exists()) - self.assertFalse((self.state_home / "proxy-client/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 / "proxy-client/state.json" - config_path = self.state_home / "proxy-client/client.json" - lock_path = self.state_home / "proxy-client/lock" - 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(0o600, lock_path.stat().st_mode & 0o777) - self.assertEqual("", lock_path.read_text(encoding="ascii")) - 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( - { - "supervisor_pid", - "xray_pid", - "xray_path", - "config_path", - "address", - "port", - "supervisor_path", - "supervisor_starttime", - "xray_starttime", - "boot_id", - }, - set(state), - ) - self.assertEqual(str(CLI.resolve()), state["supervisor_path"]) - self.assertIsInstance(state["supervisor_starttime"], int) - self.assertIsInstance(state["xray_starttime"], int) - self.assertEqual( - Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip(), - state["boot_id"], - ) - 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_held_lock_rejects_start_without_changing_managed_files(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - state_dir = self.state_home / "proxy-client" - state_dir.mkdir(parents=True, mode=0o700) - state_path = state_dir / "state.json" - config_path = state_dir / "client.json" - state_path.write_text("state sentinel\n", encoding="ascii") - config_path.write_text("config sentinel\n", encoding="ascii") - lock_path = state_dir / "lock" - with lock_path.open("w", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - result = self.run_cli("start") - - self.assertNotEqual(0, result.returncode) - self.assertIn("блокиров", result.stderr) - self.assertEqual("state sentinel\n", state_path.read_text(encoding="ascii")) - self.assertEqual("config sentinel\n", config_path.read_text(encoding="ascii")) - self.assertEqual([], self.log_entries()) - self.assert_no_secrets( - result.stdout + result.stderr + lock_path.read_text(encoding="ascii"), - profile_text, - ) - - def test_live_managed_instance_rejects_install_replace_without_changes(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state_before = self.managed_state_path().read_bytes() - xray_before = self.xray.read_bytes() - - result = self.run_cli("install", "--replace") - - self.assertNotEqual(0, result.returncode) - self.assertIn("сначала", result.stderr) - self.assertIn("stop", result.stderr) - self.assertEqual(state_before, self.managed_state_path().read_bytes()) - self.assertEqual(xray_before, self.xray.read_bytes()) - self.assertFalse((self.home / ".local/bin/xray").exists()) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.stop_and_collect(process) - - def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self): - state_dir = self.state_home / "proxy-client" - state_dir.mkdir(parents=True, mode=0o700) - state = { - "supervisor_pid": 999999998, - "xray_pid": 999999999, - "xray_path": str(self.xray.resolve()), - "config_path": str(state_dir / "client.json"), - "address": "127.0.0.1", - "port": self.port, - "supervisor_path": str(CLI.resolve()), - "supervisor_starttime": 1, - "xray_starttime": 1, - "boot_id": Path("/proc/sys/kernel/random/boot_id") - .read_text(encoding="ascii") - .strip(), - } - self.write_state(state) - config_path = state_dir / "client.json" - config_path.write_text("stale sentinel\n", encoding="ascii") - lock_path = state_dir / "lock" - with lock_path.open("w", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - status = self.run_cli("status") - - self.assertNotEqual(0, status.returncode) - self.assertIn("блокиров", status.stderr) - self.assertTrue(self.managed_state_path().exists()) - self.assertEqual("stale sentinel\n", config_path.read_text(encoding="ascii")) - - def test_reused_pid_starttime_is_not_signalled_and_state_is_preserved(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - original = self.read_state() - changed = dict(original) - changed["xray_starttime"] += 1 - self.write_state(changed) - marker = self.root / "reused-pid-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.assertTrue(self.managed_state_path().exists()) - self.assertFalse(marker.exists()) - self.assertTrue(process.poll() is None) - - self.write_state(original) - stdout, stderr = self.stop_and_collect(process) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_wrong_xray_executable_is_not_signalled_and_state_is_preserved(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - original = self.read_state() - changed = dict(original) - changed["xray_path"] = str(Path("/bin/false").resolve()) - self.write_state(changed) - - stopped = self.run_cli("stop") - self.assertNotEqual(0, stopped.returncode) - self.assertIn("вручную", stopped.stderr) - self.assertTrue(self.managed_state_path().exists()) - self.assertIsNone(process.poll()) - - self.write_state(original) - stdout, stderr = self.stop_and_collect(process) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_boot_id_mismatch_cleans_stale_files_without_signalling_processes(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state = self.read_state() - state["boot_id"] = "00000000-0000-0000-0000-000000000000" - self.write_state(state) - os.kill(state["supervisor_pid"], signal.SIGKILL) - process.wait(timeout=2) - - status = self.run_cli("status") - self.assertNotEqual(0, status.returncode) - self.assertIn("устаревшее", status.stderr) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - self.assertTrue(Path("/proc/{}".format(state["xray_pid"])).exists()) - - os.kill(state["xray_pid"], signal.SIGKILL) - stdout, stderr = process.communicate(timeout=4) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_dead_pids_allow_stale_state_and_config_cleanup(self): - state_dir = self.state_home / "proxy-client" - state_dir.mkdir(parents=True, mode=0o700) - state = { - "supervisor_pid": 999999998, - "xray_pid": 999999999, - "xray_path": str(self.xray.resolve()), - "config_path": str(state_dir / "client.json"), - "address": "127.0.0.1", - "port": self.port, - "supervisor_path": str(CLI.resolve()), - "supervisor_starttime": 1, - "xray_starttime": 1, - "boot_id": Path("/proc/sys/kernel/random/boot_id") - .read_text(encoding="ascii") - .strip(), - } - self.write_state(state) - (state_dir / "client.json").write_text("stale\n", encoding="ascii") - - status = self.run_cli("status") - self.assertNotEqual(0, status.returncode) - self.assertIn("устаревшее", status.stderr) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((state_dir / "client.json").exists()) - - def test_sigkill_supervisor_stop_terminates_confirmed_orphan_and_cleans(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state = self.read_state() - run_count = len( - [entry for entry in self.log_entries() if entry["kind"] == "run"] - ) - alternate_profile = self.write_profile(self.xhttp_profile(), explicit=True) - repeated = self.run_cli("start") - switched = self.run_cli("start", "-f", str(alternate_profile)) - for result in (repeated, switched): - self.assertNotEqual(0, result.returncode) - self.assertIn("блокиров", result.stderr) - self.assertEqual( - run_count, - len([entry for entry in self.log_entries() if entry["kind"] == "run"]), - ) - - lock_path = self.state_home / "proxy-client/lock" - with lock_path.open("r+", encoding="ascii") as lock: - with self.assertRaises(OSError): - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - - os.kill(state["supervisor_pid"], signal.SIGKILL) - process.wait(timeout=2) - with lock_path.open("r+", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - fcntl.flock(lock, fcntl.LOCK_UN) - - status = self.run_cli("status") - env = self.run_cli("env") - marker = self.root / "orphan-child-ran" - run = self.run_cli("run", "--", "touch", str(marker)) - for result in (status, env, run): - self.assertNotEqual(0, result.returncode) - self.assertIn("сирот", result.stderr) - self.assertFalse(marker.exists()) - - stopped = self.run_cli("stop") - self.assertEqual(0, stopped.returncode, stopped.stderr) - self.assertIn("сирот", stopped.stdout) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - stdout, stderr = process.communicate(timeout=2) - self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text) - - self.port = self.free_port() - self.write_machine_config("xray") - replacement = self.start() - replacement_stdout, replacement_stderr = self.stop_and_collect(replacement) - self.assert_no_secrets( - replacement_stdout + replacement_stderr, profile_text - ) - - def test_unverifiable_live_child_preserves_state_and_config(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - self.environment["PROXY_CLIENT_STOP_TIMEOUT"] = "0.2" - process = self.start() - state = self.read_state() - self.xray.unlink() - - os.kill(state["supervisor_pid"], signal.SIGTERM) - process.wait(timeout=3) - state_exists = self.managed_state_path().exists() - config_path = self.state_home / "proxy-client/client.json" - config_exists = config_path.exists() - child_alive = Path("/proc/{}".format(state["xray_pid"])).exists() - if child_alive: - os.kill(state["xray_pid"], signal.SIGKILL) - deadline = time.monotonic() + 2 - while ( - Path("/proc/{}".format(state["xray_pid"])).exists() - and time.monotonic() < deadline - ): - time.sleep(0.02) - stdout, stderr = process.communicate(timeout=2) - - self.assertTrue(child_alive) - self.assertTrue(state_exists) - self.assertTrue(config_exists) - self.assertNotEqual(0, process.returncode) - self.assertIn("вручную", stderr) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_orphan_ignoring_sigterm_is_rechecked_killed_and_cleaned(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - self.environment["FAKE_IGNORE_TERM"] = "1" - process = self.start() - state = self.read_state() - os.kill(state["supervisor_pid"], signal.SIGKILL) - process.wait(timeout=2) - - stopped = self.run_cli( - "stop", environment={"PROXY_CLIENT_STOP_TIMEOUT": "0.2"} - ) - self.assertEqual(0, stopped.returncode, stopped.stderr) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - signals = [ - entry["signum"] - for entry in self.log_entries() - if entry["kind"] == "signal" - ] - self.assertEqual([signal.SIGTERM], signals) - stdout, stderr = process.communicate(timeout=2) - self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text) - - def test_occupied_port_fails_without_touching_listener_or_leaving_generated_files(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - with socket.socket() as listener: - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.bind(("127.0.0.1", self.port)) - listener.listen(1) - result = self.run_cli("start") - with socket.create_connection(("127.0.0.1", self.port), timeout=1): - pass - - self.assertNotEqual(0, result.returncode) - self.assertIn("порт {}".format(self.port), result.stderr) - self.assertIn("занят", result.stderr) - self.assertEqual([], self.log_entries()) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - - def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self): - invalid_versions = ( - "Xray 25.12.8", - "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("sni=front.example", "sni=bad%FF"), - 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 = ( - ("", False), - ("&allowInsecure=false", False), - ("&allowInsecure=false&insecure=false", False), - ("&insecure=true", True), - ("&allowInsecure=true&insecure=true", True), - ) - for fields, warns in cases: - with self.subTest(fields=fields): - profile_text = self.xhttp_profile(fields) - 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" - ) - tls = config["outbounds"][0]["streamSettings"]["tlsSettings"] - self.assertNotIn("allowInsecure", tls) - 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() - - 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)], - cwd=str(self.root), - 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") + def start(self, **environment): + """Запускает start и ждёт строку готовности; возвращает процесс.""" process = subprocess.Popen( [str(CLI), "start"], cwd=str(self.root), + env=dict(self.environment, **environment), 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) + self.addCleanup(self.kill_leftovers, process) + watchdog = threading.Timer(10, process.kill) + watchdog.start() + self.start_output = "" + try: + for line in process.stdout: + self.start_output += line + if READY in line: + return process + finally: + watchdog.cancel() + self.fail("start не дошёл до готовности: " + process.communicate()[1]) - 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 / "proxy-client/state.json").exists()) - self.assertFalse((self.state_home / "proxy-client/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 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() + process.communicate() - 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) + def finish(self, process): + stdout, stderr = process.communicate(timeout=10) + return self.start_output + stdout, stderr - 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) + def xray_pid(self): + return json.loads((self.state_dir / "state.json").read_text(encoding="utf-8"))[ + "xray_pid" + ] - 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 / "proxy-client/client.json").exists()) + def assert_no_state_files(self): + self.assertFalse((self.state_dir / "state.json").exists()) + self.assertFalse((self.state_dir / "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-client/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 assert_no_secrets(self, text): + for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe="")): + self.assertNotIn(secret, 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 / "proxy-client" - 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"), - } - (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) + def test_full_lifecycle(self): 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-client/config" - config_path.write_text( - config_path.read_text(encoding="utf-8").replace( - "port = {}".format(managed_port), "port = {}".format(foreign_port) - ), - encoding="utf-8", + xray_pid = self.xray_pid() + self.assertEqual(0o700, self.state_dir.stat().st_mode & 0o777) + for name in ("state.json", "client.json"): + self.assertEqual(0o600, (self.state_dir / name).stat().st_mode & 0o777) + + status = self.run_cli("status") + env = self.run_cli("env") + child = self.run_cli( + "run", "--", sys.executable, "-c", "import os; print(os.environ['ALL_PROXY'])" ) - 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()) + second = self.run_cli("start") + 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:{}\n".format(self.port), env.stdout) + self.assertEqual("socks5://127.0.0.1:{}\n".format(self.port), child.stdout) + self.assertEqual(1, second.returncode) + self.assertIn("уже запущен", second.stderr) + stopped = self.run_cli("stop") - stdout, stderr = process.communicate(timeout=4) + stdout, stderr = self.finish(process) 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() + self.assertFalse(pid_alive(xray_pid)) + self.assert_no_state_files() + self.assertIn("Внешний IP через прокси: 203.0.113.77", stdout) + self.assertIn("длина пути: {}".format(len(PATH_SECRET)), stdout) + self.assert_no_secrets(stdout + stderr + status.stdout + env.stdout) - 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 / "proxy-client/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_ctrl_c_stops_xray_and_removes_files(self): + process = self.start() + xray_pid = self.xray_pid() + process.send_signal(signal.SIGINT) + self.finish(process) + self.assertEqual(0, process.returncode) + self.assertFalse(pid_alive(xray_pid)) + self.assert_no_state_files() - def test_absolute_relative_and_path_symlink_cli_invocations_are_manageable(self): - proxy_bin = self.root / "proxy-bin" - proxy_bin.mkdir() - (proxy_bin / "proxy-client").symlink_to(CLI) - invocations = ( - ([str(CLI)], self.root, self.environment), - ([os.path.relpath(str(CLI), str(self.root))], self.root, self.environment), - ( - ["proxy-client"], - self.root, - dict( - self.environment, - PATH=str(proxy_bin) - + os.pathsep - + str(self.bin_dir) - + os.pathsep - + os.environ["PATH"], - ), + def test_stop_finishes_xray_left_after_killed_supervisor(self): + process = self.start() + xray_pid = self.xray_pid() + process.kill() + process.wait() + + status = self.run_cli("status") + self.assertEqual(1, status.returncode) + self.assertIn("proxy-client stop", status.stderr) + restarted = self.run_cli("start") + self.assertEqual(1, restarted.returncode) + self.assertIn("proxy-client stop", restarted.stderr) + + stopped = self.run_cli("stop") + self.assertEqual(0, stopped.returncode, stopped.stderr) + self.assertFalse(pid_alive(xray_pid)) + self.assert_no_state_files() + + def test_stale_files_after_processes_died_are_removed(self): + self.state_dir.mkdir(parents=True) + (self.state_dir / "state.json").write_text( + json.dumps( + {"supervisor_pid": 999999998, "xray_pid": 999999999, "xray_path": "/x"} ), + encoding="utf-8", ) - 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 / "proxy-client/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") + (self.state_dir / "client.json").write_text("{}", encoding="utf-8") + + result = self.run_cli("status") + self.assertEqual(1, result.returncode) + self.assertIn("не запущен", result.stderr) + self.assert_no_state_files() + + def test_broken_state_file_is_named_in_error(self): + self.state_dir.mkdir(parents=True) + state = self.state_dir / "state.json" + state.write_text("not json", encoding="utf-8") + result = self.run_cli("stop") + self.assertEqual(1, result.returncode) + self.assertIn("rm {}".format(state), result.stderr) + + def test_busy_port_is_reported(self): + with socket.socket() as listener: + listener.bind(("127.0.0.1", self.port)) + listener.listen() + result = self.run_cli("start") + self.assertEqual(1, result.returncode) + self.assertIn("порт {} занят".format(self.port), result.stderr) + self.assert_no_state_files() + + def test_non_loopback_address_is_refused(self): + self.write_machine_config(address="192.0.2.10") + result = self.run_cli("start") + self.assertEqual(1, result.returncode) + self.assertIn("loopback", result.stderr) + + def test_open_profile_names_target_file_and_gives_chmod_command(self): + target = self.write_profile(self.root / "real profile.url", mode=0o640) + link = self.root / "link.url" + link.symlink_to(target) + result = self.run_cli("start", "-f", str(link)) + self.assertEqual(1, result.returncode) + self.assertIn( + "файл профиля {} доступен другим пользователям; " + "выполните: chmod 600 '{}'".format(target, target), + result.stderr, + ) + self.assert_no_secrets(result.stdout + result.stderr) + + def test_failed_config_test_leaves_no_files_and_no_secrets(self): + result = self.run_cli("start", FAKE_TEST_FAIL="1") + self.assertEqual(1, result.returncode) + self.assertIn("run -test", result.stderr) + self.assert_no_state_files() + self.assert_no_secrets(result.stdout + result.stderr) + + def test_failed_ip_probe_is_only_a_warning(self): + process = self.start(FAKE_IP_FAIL="1") + self.assertIsNone(process.poll()) + process.send_signal(signal.SIGINT) + _stdout, stderr = self.finish(process) + self.assertEqual(0, process.returncode) + self.assertIn("Предупреждение: внешний IP", stderr) if __name__ == "__main__":