From 45938cde98c91a4165fc968d5ec91f55ea94374e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 3 Nov 2026 07:17:23 +0100 Subject: [PATCH] =?UTF-8?q?refactor(proxy-client):=20=D0=B2=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D1=91=D0=BD=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Выбор путей, прав профиля, вывода Xray и подсказки остановки перенесён из глобальных переменных в неизменяемый RuntimeContext. Команды и управление процессом получают контекст явно. Проверка: прежние 72 теста без изменений прошли на Python 3.8 и 3.14; оба shell-набора прошли. --- ...02-proxy-client-profiles-service-design.md | 5 +- proxy-client/.local/bin/proxy-client | 224 +++++++++--------- 2 files changed, 118 insertions(+), 111 deletions(-) diff --git a/docs/specs/2026-11-02-proxy-client-profiles-service-design.md b/docs/specs/2026-11-02-proxy-client-profiles-service-design.md index a46a715..bf821c1 100644 --- a/docs/specs/2026-11-02-proxy-client-profiles-service-design.md +++ b/docs/specs/2026-11-02-proxy-client-profiles-service-design.md @@ -115,7 +115,10 @@ Xray нужен другой порт. Установка службы не ме Применяем `codebase-design`: выбор профиля и все его пути должны определяться в одном месте, чтобы команды не повторяли правила размещения файлов. -Управление процессом использует уже выбранные настройки и расположение файлов. +Пути, правила доступа к профилю, вывод Xray и подсказка остановки собраны +в неизменяемый контекст запуска. Он создаётся при выборе пользовательского +или системного режима и явно передаётся функциям. Управление процессом +использует готовые значения, без глобального переключателя режима. Системная установка скрывает размещение, права и взаимодействие с systemd за небольшим интерфейсом; не копирует разбор VLESS и управление Xray. diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index 396edc1..040f8ff 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -30,6 +30,8 @@ import urllib.request import uuid import zipfile from pathlib import Path +from dataclasses import dataclass +from typing import Optional from urllib.parse import parse_qsl, unquote, urlsplit @@ -120,36 +122,56 @@ class VlessProfile: return self.query["security"] -SELECTED_PROFILE = "default" -SYSTEM_MODE = False -SYSTEM_CONFIG = Path("/etc/proxy-client") -SYSTEM_RUNTIME = Path("/run/proxy-client") -SYSTEM_XRAY = Path("/usr/local/lib/proxy-client/xray") +@dataclass(frozen=True) +class RuntimeContext: + """Размещение и правила выбранного запуска; создаётся до выполнения команды.""" + + config_dir: Path + state_dir: Path + xray_path: Optional[Path] + profile_forbidden_mode: int + profile_repair: str + xray_output: Optional[int] + stop_hint: str + + @property + def state_path(self): + return self.state_dir / "state.json" + + @property + def generated_config_path(self): + return self.state_dir / "client.json" -def config_home(): - if SYSTEM_MODE: - return SYSTEM_CONFIG - root = os.environ.get("XDG_CONFIG_HOME") - base = (Path(root).expanduser() if root else Path.home() / ".config") / "proxy-client" - return base if SELECTED_PROFILE == "default" else base / "profiles" / SELECTED_PROFILE +def user_context(profile="default"): + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", profile): + raise ProxyError("недопустимое имя профиля") + config_root = os.environ.get("XDG_CONFIG_HOME") + state_root = os.environ.get("XDG_STATE_HOME") + config_dir = (Path(config_root).expanduser() if config_root else Path.home() / ".config") / "proxy-client" + state_dir = (Path(state_root).expanduser() if state_root else Path.home() / ".local/state") / "proxy-client" + stop_hint = "Ctrl+C или proxy-client stop" + if profile != "default": + config_dir = config_dir / "profiles" / profile + state_dir = state_dir / "profiles" / profile + if not (config_dir / "config").is_file(): + raise ProxyError("неизвестный профиль: {}".format(profile)) + stop_hint = "Ctrl+C или proxy-client --profile {} stop".format(profile) + return RuntimeContext(config_dir, state_dir, None, 0o077, + "chmod 600 {0}", None, stop_hint) -def state_home(): - if SYSTEM_MODE: - return SYSTEM_RUNTIME - root = os.environ.get("XDG_STATE_HOME") - base = Path(root).expanduser() if root else Path.home() / ".local/state" - base = base / "proxy-client" - return base if SELECTED_PROFILE == "default" else base / "profiles" / SELECTED_PROFILE - - -def state_path(): - return state_home() / "state.json" - - -def generated_config_path(): - return state_home() / "client.json" +def service_context(arguments): + config_dir = Path(arguments.config_dir) + state_dir = Path(arguments.runtime_dir) + xray_path = Path(arguments.xray) + if not all(path.is_absolute() for path in (config_dir, state_dir, xray_path)): + raise ProxyError("системные пути должны быть абсолютными") + if not (config_dir / "config").is_file(): + raise ProxyError("нет системного config") + return RuntimeContext(config_dir, state_dir, xray_path, 0o037, + "sudo chown root:proxy-client {0}; sudo chmod 640 {0}", + subprocess.DEVNULL, "sudo systemctl stop proxy-client") def validate_address(value): @@ -170,8 +192,8 @@ def validate_address(value): raise ProxyError("поле address должно быть IP-адресом или простым ASCII-именем") -def load_config(): - path = config_home() / "config" +def load_config(context): + path = context.config_dir / "config" if not path.exists(): if path.is_symlink(): raise ProxyError("машинный конфиг {} ссылается на отсутствующий файл".format(path)) @@ -238,9 +260,9 @@ def check_reachable(config): ) -def check_proxy(config): +def check_proxy(config, context): if config.proxy_type == "xray": - require_supervised_xray(config) + require_supervised_xray(config, context) check_reachable(config) @@ -287,13 +309,12 @@ def write_private_json(path, value): path.chmod(0o600) -def load_profile(path): +def load_profile(path, context): # Права проверяем у конечного файла: у симлинка они ничего не значат. resolved = path.expanduser().resolve(strict=True) - if resolved.stat().st_mode & (0o037 if SYSTEM_MODE else 0o077): + if resolved.stat().st_mode & context.profile_forbidden_mode: target = shlex.quote(str(resolved)) - repair = ("sudo chown root:proxy-client {0}; sudo chmod 640 {0}".format(target) - if SYSTEM_MODE else "chmod 600 {}".format(target)) + repair = context.profile_repair.format(target) raise ProxyError( "файл профиля {} доступен другим пользователям; выполните: {}".format( resolved, repair @@ -547,8 +568,8 @@ def build_xray_config(profile, local_address, local_port): } -def resolve_xray(): - found = str(SYSTEM_XRAY) if SYSTEM_MODE else shutil.which("xray") +def resolve_xray(context): + found = str(context.xray_path) if context.xray_path is not None else shutil.which("xray") if found is None: raise ProxyError("xray не найден в PATH; установите его: proxy-client install") return str(Path(found).resolve(strict=True)) @@ -623,7 +644,8 @@ def install_xray(destination, downloader): shutil.rmtree(str(staging), ignore_errors=True) -def command_install(replace=False, downloader=download_file): +def command_install(replace=False, downloader=download_file, context=None): + context = context if context is not None else user_context() destination = Path.home() / ".local/bin/xray" if destination.is_dir(): raise ProxyError("назначение {} является каталогом".format(destination)) @@ -640,7 +662,7 @@ def command_install(replace=False, downloader=download_file): else: install_xray(destination, downloader) print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) - if managed_xray_running(): + if managed_xray_running(context): print("Работающий Xray остался прежним; перезапустите proxy-client start") found = shutil.which("xray") if found is None or Path(found).resolve() != destination.resolve(): @@ -667,8 +689,8 @@ def run_xray_test(path, config_path): raise ProxyError("созданная конфигурация не прошла xray run -test") -def read_state(): - path = state_path() +def read_state(context): + path = context.state_path try: state = json.loads(path.read_text(encoding="utf-8")) keys = ("supervisor_pid", "xray_pid", "xray_path", "address", "port") @@ -701,32 +723,32 @@ def supervisor_running(state): return b"proxy-client" in cmdline -def remove_state_files(): - for path in (state_path(), generated_config_path()): +def remove_state_files(context): + for path in (context.state_path, context.generated_config_path): try: path.unlink() except FileNotFoundError: pass -def managed_state(): +def managed_state(context): """Состояние работающего Xray; устаревшие служебные файлы удаляет.""" - state = read_state() + state = read_state(context) if not xray_running(state): - remove_state_files() + remove_state_files(context) raise ProxyError("управляемый Xray не запущен; устаревшие служебные файлы удалены") return state -def managed_xray_running(): +def managed_xray_running(context): try: - return xray_running(read_state()) + return xray_running(read_state(context)) except ProxyError: return False -def require_supervised_xray(config): - state = managed_state() +def require_supervised_xray(config, context): + state = managed_state(context) if not supervisor_running(state): raise ProxyError( "Xray работает без proxy-client start (его процесс завершён); " @@ -736,7 +758,7 @@ def require_supervised_xray(config): raise ProxyError( "Xray запущен на {}:{}, а {} указывает {}:{}; чтобы применить конфиг, " "выполните proxy-client stop, затем proxy-client start".format( - state["address"], state["port"], config_home() / "config", + state["address"], state["port"], context.config_dir / "config", config.address, config.port, ) ) @@ -772,9 +794,9 @@ def check_port_available(address, port): raise ProxyError("локальный порт {} занят или недоступен".format(port)) -def acquire_start_lock(): - private_directory(state_home()) - lock = (state_home() / "lock").open("a") +def acquire_start_lock(context): + private_directory(context.state_dir) + lock = (context.state_dir / "lock").open("a") try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: @@ -848,8 +870,8 @@ def interrupt(_signum, _frame): raise KeyboardInterrupt -def command_status(config): - check_proxy(config) +def command_status(config, context): + check_proxy(config, context) if config.proxy_type == "external": print("{} (external) доступен по адресу {}:{}".format(config.name, config.address, config.port)) else: @@ -857,21 +879,21 @@ def command_status(config): return 0 -def command_env(config): - check_proxy(config) +def command_env(config, context): + check_proxy(config, context) 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): +def command_run(config, remainder, context): 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) + check_proxy(config, context) child_environment = os.environ.copy() child_environment.update(dict(environment_values(config))) try: @@ -882,10 +904,10 @@ def command_run(config, remainder): return exit_code(completed.returncode) -def command_start(config, profile_file): +def command_start(config, profile_file, context): if config.proxy_type != "xray": raise ProxyError( - "start доступен только для type=xray; измените {}".format(config_home() / "config") + "start доступен только для type=xray; измените {}".format(context.config_dir / "config") ) try: loopback = ipaddress.ip_address(config.address).is_loopback @@ -893,43 +915,43 @@ def command_start(config, profile_file): loopback = False if not loopback: raise ProxyError("для type=xray поле address должно быть loopback-адресом, например 127.0.0.1") - lock = acquire_start_lock() + lock = acquire_start_lock(context) try: - return run_managed_xray(config, profile_file) + return run_managed_xray(config, profile_file, context) finally: lock.close() -def run_managed_xray(config, profile_file): - if state_path().exists(): - if xray_running(read_state()): +def run_managed_xray(config, profile_file, context): + if context.state_path.exists(): + if xray_running(read_state(context)): raise ProxyError( "Xray остался от прошлого proxy-client start; сначала выполните proxy-client stop" ) - remove_state_files() + remove_state_files(context) check_port_available(ipaddress.ip_address(config.address), config.port) - profile_path = Path(profile_file) if profile_file else config_home() / "default.url" - profile = load_profile(profile_path) - executable = resolve_xray() + profile_path = Path(profile_file) if profile_file else context.config_dir / "default.url" + profile = load_profile(profile_path, context) + executable = resolve_xray(context) check_xray_version(executable) write_private_json( - generated_config_path(), build_xray_config(profile, config.address, config.port) + context.generated_config_path, build_xray_config(profile, config.address, config.port) ) child = None try: - run_xray_test(executable, generated_config_path()) + run_xray_test(executable, context.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())], - stdout=subprocess.DEVNULL if SYSTEM_MODE else None, - stderr=subprocess.DEVNULL if SYSTEM_MODE else None, + [executable, "run", "-c", str(context.generated_config_path)], + stdout=context.xray_output, + stderr=context.xray_output, ) write_private_json( - state_path(), + context.state_path, { "supervisor_pid": os.getpid(), "xray_pid": child.pid, @@ -943,15 +965,9 @@ def run_managed_xray(config, profile_file): ): raise ProxyError("Xray не открыл локальный порт {}".format(config.port)) diagnose_ip(config) - if SYSTEM_MODE: - stop_hint = "sudo systemctl stop proxy-client" - elif SELECTED_PROFILE == "default": - stop_hint = "Ctrl+C или proxy-client stop" - else: - stop_hint = "Ctrl+C или proxy-client --profile {} stop".format(SELECTED_PROFILE) print( "Управляемый Xray работает на {}:{}; остановка: {}".format( - config.address, config.port, stop_hint + config.address, config.port, context.stop_hint ), flush=True, ) @@ -966,11 +982,11 @@ def run_managed_xray(config, profile_file): except subprocess.TimeoutExpired: child.kill() child.wait() - remove_state_files() + remove_state_files(context) -def command_stop(): - state = managed_state() +def command_stop(context): + state = managed_state(context) # Живой супервизор сам остановит Xray; иначе Xray гасим напрямую. running_supervisor = supervisor_running(state) target = state["supervisor_pid"] if running_supervisor else state["xray_pid"] @@ -984,7 +1000,7 @@ def command_stop(): 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() + remove_state_files(context) print("Управляемый Xray остановлен") return 0 @@ -1087,7 +1103,7 @@ def service_install(args): if config.proxy_type != "xray" or not ipaddress.ip_address(config.address).is_loopback: raise ProxyError("службе нужен type=xray и loopback-адрес") if not (settings / "default.url").exists(): - load_profile(source / "default.url") + load_profile(source / "default.url", user_context()) check_xray_version(str(xray)) files = { "usr/local/lib/proxy-client/proxy-client": (Path(__file__).read_bytes(), 0o755), @@ -1189,7 +1205,6 @@ def build_parser(): def main(): - global SELECTED_PROFILE, SYSTEM_MODE, SYSTEM_CONFIG, SYSTEM_RUNTIME, SYSTEM_XRAY parser = build_parser() arguments = parser.parse_args() if arguments.subcommand is None: @@ -1203,33 +1218,22 @@ def main(): raise ProxyError("service не использует пользовательские профили") if arguments.service_action == "install": return service_install(arguments) - SYSTEM_MODE = True - SYSTEM_CONFIG = Path(arguments.config_dir) - SYSTEM_RUNTIME = Path(arguments.runtime_dir) - SYSTEM_XRAY = Path(arguments.xray) - if not all(p.is_absolute() for p in (SYSTEM_CONFIG, SYSTEM_RUNTIME, SYSTEM_XRAY)): - raise ProxyError("системные пути должны быть абсолютными") - if not (SYSTEM_CONFIG / "config").is_file(): - raise ProxyError("нет системного config") - return command_start(load_config(), None) - SELECTED_PROFILE = arguments.profile - if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", SELECTED_PROFILE): - raise ProxyError("недопустимое имя профиля") - if SELECTED_PROFILE != "default" and not (config_home() / "config").is_file(): - raise ProxyError("неизвестный профиль: {}".format(SELECTED_PROFILE)) + context = service_context(arguments) + return command_start(load_config(context), None, context) + context = user_context(arguments.profile) if arguments.subcommand == "install": - return command_install(arguments.replace) + return command_install(arguments.replace, context=context) if arguments.subcommand == "stop": - return command_stop() - config = load_config() + return command_stop(context) + config = load_config(context) if arguments.subcommand == "status": - return command_status(config) + return command_status(config, context) if arguments.subcommand == "env": - return command_env(config) + return command_env(config, context) if arguments.subcommand == "run": - return command_run(config, arguments.arguments) + return command_run(config, arguments.arguments, context) if arguments.subcommand == "start": - return command_start(config, arguments.profile_file) + return command_start(config, arguments.profile_file, context) except (ProxyError, OSError, ValueError, subprocess.CalledProcessError) as error: sys.stderr.write("Ошибка: {}\n".format(error)) return 1