From 203e515479d1eca9d3f3f0ba83f83c30d01a0de8 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 4 Nov 2026 21:15:23 +0100 Subject: [PATCH] =?UTF-8?q?feat(proxy-client):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D0=BB=D1=83=D0=B6=D0=B1=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: установка с нуля и переход из ручного Xray одной командой. - Что: план с подтверждением или --yes, скрытый ввод ссылки, проверка Xray, перенос настроек, запуск службы и переключение домашнего профиля; безопасное продолжение после сбоя и сохранение существующих настроек. - Проверка: cross-review Claude и исправление находок; по 108 тестов на Python 3.8 и 3.14 под PTY, 18 Bash-проверок. После аудита уточнена фикстура повторного запуска и отдельно проверена на обеих версиях Python. Настоящие службы и настройки машины не изменялись. --- ...02-proxy-client-profiles-service-design.md | 75 ++- proxy-client/.local/bin/proxy-client | 430 ++++++++++++++- proxy-client/README.md | 134 ++++- tests/test_proxy_profiles_service.py | 147 ++++- tests/test_proxy_service_setup.py | 520 ++++++++++++++++++ 5 files changed, 1250 insertions(+), 56 deletions(-) create mode 100644 tests/test_proxy_service_setup.py 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 bf821c1..9ece31d 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 @@ -77,17 +77,50 @@ Bash-команды без явного выбора продолжают исп служебной учётной записи. Пользовательские команды остановки не управляют системным экземпляром: за него отвечает systemd. -Установка оформляется подкомандой `service install`, в соответствии с -существующим интерфейсом CLI. Она размещает необходимые файлы и регистрирует -службу. Запуск сейчас и автозапуск после загрузки выбираются явно; установщик -может предоставить явные параметры для совмещения этих действий с установкой. -Точное написание параметров определяется при реализации. +Установка оформляется одной подкомандой `service install`. В терминале она +собирает недостающие настройки, показывает план и после подтверждения +выполняет полный переход на службу. `--yes` (`--auto-yes`) принимает план +без вопроса. Без терминала для нового подключения нужен закрытый `--file`; +секрет не передаётся в argv или окружении. В терминале применяется скрытый +ввод. Новый профиль получает адрес `127.0.0.1` и порт `12334` по умолчанию. -Для первоначального заполнения можно скопировать выбранный пользовательский -профиль. Источник должен быть указан однозначно: повышение прав не должно -подменить его домашним каталогом root. Копия становится самостоятельной: -дальнейшие изменения домашнего профиля не меняют службу. Повторная установка -сохраняет существующие настройки и не перезапускает службу скрыто. +Для переноса используются сохранённые `config` и `default.url` выбранного +профиля; `--file` переопределяет ссылку. Xray ищется в `~/.local/bin`, затем +в `PATH` и системной установке. Если его нет, скачивается закреплённая версия +с проверкой SHA-256. Параметр `--xray` позволяет указать бинарник явно. + +Полный переход состоит из подготовки снимка, проверки Xray, установки +системных файлов, остановки выбранного ручного процесса и запуска службы. +Домашний `config` меняется на `external` только после проверки активного +состояния службы и доступности её порта; прежний файл сохраняется рядом. +Остановка пользовательского процесса и изменение домашнего профиля +выполняются без повышения прав. Sudo применяется только к системной части. + +Закрытый снимок `service-setup.json` в каталоге состояния профиля позволяет +продолжить незавершённый переход. Он содержит исходный домашний config, +подготовленный config, VLESS-ссылку и путь для ручного восстановления; +права файла `600`, каталога `700`. При повторе домашние и системные настройки +сверяются со снимком до остановки процессов. Изменённые настройки не +перезаписываются, исходный файл VLESS-ссылки тоже проверяется. Ошибка конфликта +указывает команду удаления снимка, чтобы начать заново с текущими настройками; +этот сброс не меняет сами настройки и процессы. После успеха снимок удаляется. +Ошибка запуска сохраняет +прежний домашний профиль; восстановление ручного процесса требует явной +команды, прежние соединения не восстанавливаются. + +Наличие системных файлов само по себе не означает завершённый переход: +если домашний профиль ещё `xray`, мастер сверяет его с системным и завершает +перенос. Другой профиль не заменяет существующие системные настройки. +После завершённого перехода настройки службы самостоятельны. Обычное +обновление сохраняет их и включает перезапуск в подтверждаемый план; +домашний профиль не меняется и может отсутствовать. + +`--install-only`, `--config-dir` и `--root` +сохраняют режим установки файлов без мастера. Он требует готовых настроек +и Xray. Флаги `--enable`, `--start`, `--restart` задают действия явно. +`--root` не допускает действий с systemd. В этом режиме установка сама +домашний профиль не меняет и ручной процесс не останавливает. +Без терминала и без явного выбора режима команда завершается с подсказкой. Systemd отвечает за запуск, остановку и перезапуск после аварии. Явная остановка должна оставлять службу остановленной до следующего запуска. @@ -109,7 +142,8 @@ Systemd отвечает за запуск, остановку и перезап Если системная служба занимает порт прежнего пользовательского Xray, второй запуск на этом порту завершается ошибкой. Чтобы использовать системный прокси через пользовательские команды, подходит профиль типа `external`. Для второго -Xray нужен другой порт. Установка службы не меняет домашний профиль молча. +Xray нужен другой порт. Мастер меняет домашний профиль по подтверждённому +плану; режим установки только файлов его не меняет. ## Устройство модулей @@ -121,6 +155,12 @@ Xray нужен другой порт. Установка службы не ме использует готовые значения, без глобального переключателя режима. Системная установка скрывает размещение, права и взаимодействие с systemd за небольшим интерфейсом; не копирует разбор VLESS и управление Xray. +Внутри модуль отделяет подготовку плана от его применения: план хранит +снимок настроек и исходного процесса, применение управляет порядком +действий и возобновлением. Проверки проходят через публичный CLI. +Внешние команды подменяются через `PATH`, терминальный ввод, сокеты и загрузка +архива — подменой функций стандартной библиотеки в тестовом `sitecustomize`. +Разбор настроек и работа с файлами выполняются настоящим кодом. Не вводим обобщённую поддержку других менеджеров служб и дополнительные слои ради возможного будущего использования. Реально различаются @@ -151,11 +191,18 @@ Xray нужен другой порт. Установка службы не ме аварии, явная остановка и одновременная работа ручного экземпляра. Изменение текущей машины или её перезагрузка не входят в проверку по умолчанию. -Локальный набор прошёл на Python 3.8 и 3.14: 72 теста, проверки Bash-команд -и пользовательского установщика. Дополнительно проверены Bash-команды вместе +Проверка после cross-review 2026-11-04: полный набор под псевдотерминалом +прошёл на Python 3.8 и 3.14 — по 108 тестов, ещё 12 проверок Bash-команд +и 6 проверок пользовательского установщика. +Мастер проверен через CLI: настройка с нуля, перенос, обновление, отмена, +отказы и продолжение перехода. Скрытый ввод проверен в псевдотерминале. +Системные действия и скачивание в этих тестах подменены; настоящие службы +не запускались. Shellcheck в среде отсутствует. + +При первоначальной реализации отдельно проверены Bash-команды вместе с настоящим CLI и двумя локальными тестовыми портами. Unit прошёл статическую проверку в изолированном корне с тестовыми базовыми target-файлами. -Shellcheck в среде отсутствует. Эти результаты не заменяют проверку загрузки ОС. +Эти результаты не заменяют проверку загрузки ОС. ## Документация и альтернативы diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index 040f8ff..06ab7e7 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -13,7 +13,9 @@ import argparse import configparser import fcntl import hashlib +import getpass import http.client +import io import ipaddress import json import os @@ -28,6 +30,7 @@ import tempfile import time import urllib.request import uuid +import warnings import zipfile from pathlib import Path from dataclasses import dataclass @@ -143,13 +146,14 @@ class RuntimeContext: return self.state_dir / "client.json" -def user_context(profile="default"): +def user_context(profile="default", home=None): 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" + home = home if home is not None else Path.home() + config_dir = (Path(config_root).expanduser() if config_root else home / ".config") / "proxy-client" + state_dir = (Path(state_root).expanduser() if state_root else home / ".local/state") / "proxy-client" stop_hint = "Ctrl+C или proxy-client stop" if profile != "default": config_dir = config_dir / "profiles" / profile @@ -208,7 +212,10 @@ def load_config(context): def parse_config(text): parser = configparser.ConfigParser(interpolation=None, empty_lines_in_values=False) - parser.read_string(text, source="config") + try: + parser.read_string(text, source="config") + except configparser.Error: + raise ProxyError("неверный формат config; ожидается секция [proxy] с параметрами") from None if parser.sections() != ["proxy"]: raise ProxyError("нужна ровно одна секция [proxy]") section = parser["proxy"] @@ -1086,25 +1093,389 @@ def install_file(target, data, mode): os.unlink(temporary) +def service_config(text): + config = parse_config(text) + try: + loopback = ipaddress.ip_address(config.address).is_loopback + except ValueError: + loopback = False + if config.proxy_type != "xray" or not loopback: + raise ProxyError("службе нужен type=xray и loopback-адрес") + return config + + +def validate_service_source(source): + missing = [name for name in ("config", "default.url") if not (source / name).is_file()] + if missing: + raise ProxyError("не хватает настроек {} в {}; запустите proxy-client service install " + "в терминале для настройки или укажите готовый --config-dir".format( + ", ".join(missing), source)) + config = service_config((source / "config").read_text(encoding="utf-8")) + load_profile(source / "default.url", user_context()) + return config + + +@dataclass(frozen=True) +class ServiceSetupPlan: + """Снимок перехода; config_text=None означает обновление без изменения HOME.""" + context: RuntimeContext + original_config: Optional[bytes] + config_text: Optional[str] + profile_text: Optional[str] + manual_state: Optional[dict] = None + resume: bool = False + recovery_file: Optional[str] = None + source_profile: Optional[str] = None + + +def service_is_installed(root): + manifest = root / "usr/local/lib/proxy-client/installed.json" + return manifest.is_file() or manifest.with_name("installed.pending.json").is_file() + + +def render_machine_config(config, proxy_type): + parser = configparser.ConfigParser(interpolation=None) + parser["proxy"] = {"type": proxy_type, "name": config.name, + "address": config.address, "port": str(config.port), + "no_proxy_extra": ",".join(config.no_proxy_extra)} + output = io.StringIO() + parser.write(output) + return output.getvalue() + + +def setup_reset_hint(context): + snapshot = context.state_dir / "service-setup.json" + return ("Чтобы начать заново, удалите только снимок: rm -- {}\n" + "Системные и домашние настройки эта команда не меняет.").format(shlex.quote(str(snapshot))) + + +def check_setup_source(plan): + path = Path(plan.source_profile) if plan.source_profile else plan.context.config_dir / "default.url" + if plan.source_profile: + changed = not path.is_file() or path.read_text(encoding="utf-8").strip() != plan.profile_text + if not changed: + load_profile(path, plan.context) + else: + changed = path.exists() or path.is_symlink() + if changed: + raise ProxyError("исходный файл VLESS-ссылки изменился; переход остановлен. " + + setup_reset_hint(plan.context)) + + +def prepare_service_setup(args): + context = user_context(args.profile) + pending = context.state_dir / "service-setup.json" + if pending.exists(): + if pending.is_symlink() or pending.stat().st_mode & 0o077: + raise ProxyError("снимок перехода должен быть обычным закрытым файлом: {}".format(pending)) + if args.profile_file or args.port is not None: + raise ProxyError("есть незавершённый переход; повторите без --file и --port. " + + setup_reset_hint(context)) + try: + data = json.loads(pending.read_text(encoding="utf-8")) + original = data["original_config"].encode() if data["original_config"] is not None else None + service_config(data["config_text"]) + parse_vless(data["profile_text"]) + source_profile = data["source_profile"] + if source_profile is not None and not isinstance(source_profile, str): + raise ValueError("неверный путь источника") + except (KeyError, TypeError, ValueError, AttributeError, ProxyError, configparser.Error): + raise ProxyError("снимок незавершённого перехода повреждён или устарел: {}. ".format(pending) + + setup_reset_hint(context)) + manual_state = read_state(context) if managed_xray_running(context) else None + plan = ServiceSetupPlan(context, original, data["config_text"], data["profile_text"], + manual_state, True, data.get("recovery_file"), source_profile) + check_setup_source(plan) + return plan + config_path = context.config_dir / "config" + original = config_path.read_bytes() if config_path.exists() else None + config = parse_config(original.decode("utf-8")) if original is not None else None + # Установленные файлы ещё не означают завершённый переход из ручного режима. + if (Path("/") / "usr/local/lib/proxy-client/installed.json").is_file() and ( + config is None or config.proxy_type != "xray"): + if args.profile_file or args.port is not None: + raise ProxyError("настройки службы уже существуют; измените /etc/proxy-client, " + "обновление сохраняет их") + return ServiceSetupPlan(context, None, None, None) + if config_path.is_symlink(): + raise ProxyError("домашний config является симлинком; используйте --install-only") + profile_path = Path(args.profile_file).expanduser() if args.profile_file else context.config_dir / "default.url" + if profile_path.is_symlink() and not profile_path.exists(): + raise ProxyError("{} — битый симлинк; исправьте ссылку или выберите другой --file".format(profile_path)) + source_profile = None + if profile_path.is_file(): + load_profile(profile_path, context) + profile_text = profile_path.read_text(encoding="utf-8").strip() + source_profile = str(profile_path.absolute()) + elif args.profile_file or not sys.stdin.isatty(): + raise ProxyError("нет VLESS-ссылки; укажите закрытый файл через --file или запустите в терминале") + else: + # getpass не должен переходить к видимому вводу, если терминал недоступен. + with warnings.catch_warnings(): + warnings.simplefilter("error", getpass.GetPassWarning) + try: + profile_text = getpass.getpass("VLESS-ссылка (ввод скрыт): ").strip() + except getpass.GetPassWarning: + raise ProxyError("скрытый ввод недоступен; передайте ссылку через закрытый --file") + parse_vless(profile_text) + if config is None or config.proxy_type != "xray": + port = DEFAULT_PORT if args.port is None else args.port + if args.port is None and not args.yes: + raw = input("Локальный порт [{}]: ".format(DEFAULT_PORT)).strip() + if raw: + try: + port = int(raw) + except ValueError: + raise ProxyError("порт должен быть числом от 1 до 65535") + config = ProxyConfig("xray", "Xray", "127.0.0.1", port, + config.no_proxy_extra if config else DEFAULT_NO_PROXY_EXTRA) + elif args.port is not None: + config.port = args.port + text = render_machine_config(config, "xray") + service_config(text) + manual_state = read_state(context) if managed_xray_running(context) else None + return ServiceSetupPlan(context, original, text, profile_text, manual_state, + recovery_file=str(profile_path.resolve()) if args.profile_file else None, + source_profile=source_profile) + + +def find_service_xray(explicit, home, root): + if explicit: + path = Path(explicit) + if not path.is_absolute(): + raise ProxyError("xray должен быть абсолютным путём") + return path + local = home / ".local/bin/xray" + if local.is_file(): + return local + found = shutil.which("xray") + if found: + return Path(found).resolve() + installed = root / "usr/local/lib/proxy-client/xray" + return installed if installed.is_file() else None + + +def service_systemctl(*arguments, check=True): + return subprocess.run(["sudo", "--", "systemctl", *arguments], check=check) + + +def service_ready(config): + return subprocess.run(["systemctl", "is-active", "--quiet", "proxy-client.service"], + check=False).returncode == 0 and port_open(config.address, config.port) + + +def switch_home_to_service(plan): + path = plan.context.config_dir / "config" + config = service_config(plan.config_text) + replacement = render_machine_config(config, "external").encode() + current = path.read_bytes() if path.exists() else None + if not path.is_symlink() and current == replacement: + return + if path.is_symlink() or current != plan.original_config: + raise ProxyError("домашний config изменился во время установки; он оставлен без изменений. " + + setup_reset_hint(plan.context)) + private_directory(plan.context.config_dir) + if plan.original_config is not None: + descriptor, backup = tempfile.mkstemp(prefix="config.before-service.", dir=str(path.parent)) + with os.fdopen(descriptor, "wb") as stream: + stream.write(plan.original_config) + print("Прежний config сохранён: {}".format(backup)) + install_file(path, replacement, 0o600) + + +def apply_service_setup(args, plan, xray): + if plan.config_text is None: + with tempfile.TemporaryDirectory(prefix="proxy-service-update-") as temporary: + if xray is None: + xray = Path(temporary) / "xray" + install_xray(xray, download_file) + options = vars(args).copy() + options.update(config_dir=str(plan.context.config_dir), xray=str(xray), + profile="default", enable=True, start=False, restart=True) + result = service_install(argparse.Namespace(**options)) + if result == 0: + print("Служба обновлена и готова; её настройки и домашний профиль сохранены.") + return result + config = service_config(plan.config_text) + check_setup_source(plan) + config_path = plan.context.config_dir / "config" + current = config_path.read_bytes() if config_path.exists() else None + if config_path.is_symlink() or current not in ( + plan.original_config, render_machine_config(config, "external").encode()): + raise ProxyError("домашний config изменился; переход остановлен без его замены. " + + setup_reset_hint(plan.context)) + replacing_installed = service_is_installed(Path("/")) + if not replacing_installed and (plan.manual_state is None or (config.address, config.port) != ( + plan.manual_state["address"], plan.manual_state["port"])): + check_port_available(ipaddress.ip_address(config.address), config.port) + with tempfile.TemporaryDirectory(prefix="proxy-service-") as temporary: + source = Path(temporary) + install_file(source / "config", plan.config_text.encode(), 0o600) + install_file(source / "default.url", (plan.profile_text + "\n").encode(), 0o600) + if xray is None: + print("Скачивание проверенного Xray {}…".format(XRAY_VERSION)) + xray = source / "xray" + install_xray(xray, download_file) + check_xray_version(str(xray)) + generated = source / "client.json" + write_private_json(generated, build_xray_config(parse_vless(plan.profile_text), + config.address, config.port)) + run_xray_test(str(xray), generated) + private_directory(plan.context.state_dir) + pending = plan.context.state_dir / "service-setup.json" + install_file(pending, json.dumps({ + "original_config": plan.original_config.decode() if plan.original_config is not None else None, + "config_text": plan.config_text, "profile_text": plan.profile_text, + "recovery_file": plan.recovery_file, + "source_profile": plan.source_profile, + }).encode(), 0o600) + options = vars(args).copy() + options.update(config_dir=str(source), xray=str(xray), profile="default", + enable=False, start=False, restart=False, expect_profile=True) + result = service_install(argparse.Namespace(**options)) + if result: + if pending.exists(): + sys.stderr.write("Снимок сохранён для повторной попытки. " + + setup_reset_hint(plan.context) + "\n") + return result + check_setup_source(plan) + if managed_xray_running(plan.context): + if read_state(plan.context) != plan.manual_state: + raise ProxyError("ручной Xray изменился во время установки; переход остановлен") + command_stop(plan.context) + try: + if replacing_installed: + service_systemctl("stop", "proxy-client.service") + check_port_available(ipaddress.ip_address(config.address), config.port) + service_systemctl("enable", "--now", "proxy-client.service") + if not wait_until(lambda: service_ready(config), 10): + raise ProxyError("служба не стала готова") + except (ProxyError, OSError, subprocess.CalledProcessError, KeyboardInterrupt) as error: + cleanup = service_systemctl("disable", "--now", "proxy-client.service", check=False) + recovery = ["proxy-client"] + if args.profile != "default": + recovery.extend(["--profile", args.profile]) + recovery.append("start") + if plan.recovery_file: + recovery.extend(["-f", plan.recovery_file]) + hint = (" Ручной Xray остановлен; вернуть его: {}.".format( + " ".join(shlex.quote(part) for part in recovery)) if plan.manual_state else "") + if cleanup.returncode: + hint += " Не удалось отключить службу; выполните sudo systemctl disable --now proxy-client." + raise ProxyError("переход не завершён; домашний config не изменён. " + "Проверьте sudo journalctl -u proxy-client -b." + hint) from error + try: + switch_home_to_service(plan) + except (ProxyError, OSError) as error: + raise ProxyError("служба уже запущена, но домашний профиль не удалось переключить: {}".format(error)) + pending.unlink() + print("Служба готова на {}:{}. Домашний профиль переключён на external.".format( + config.address, config.port)) + return 0 + + +def setup_service(args): + if os.geteuid() == 0: + raise ProxyError("для настройки и перехода запустите proxy-client service install без sudo; " + "для установки только файлов используйте --install-only") + plan = prepare_service_setup(args) + xray = find_service_xray(args.xray, Path.home(), Path("/")) + if xray is None: + print("Xray {} будет скачан и проверен перед установкой.".format(XRAY_VERSION)) + if plan.config_text is None: + print("План: обновить и перезапустить службу, включить автозапуск; настройки сохранить.") + else: + config = service_config(plan.config_text) + print("План: установить системную службу на {}:{}, включить автозапуск " + "и переключить текущий профиль.".format(config.address, config.port)) + if plan.original_config and parse_config(plan.original_config.decode()).proxy_type == "external": + print("Текущий внешний прокси будет заменён локальной службой; прежний config сохранится.") + if plan.resume: + print("Продолжение незавершённого перехода; изменённые настройки не будут заменены.") + if plan.manual_state is not None: + print("После установки файлов выбранный ручной Xray будет остановлен; " + "текущие соединения прервутся.") + if not args.yes and input("Продолжить? [y/N]: ").strip().lower() not in ("y", "yes", "д", "да"): + print("Установка отменена.") + return 0 + return apply_service_setup(args, plan, xray) + + +def command_service_install(args): + if args.port is not None and not 1 <= args.port <= 65535: + raise ProxyError("порт должен быть числом от 1 до 65535") + if args.yes and (args.root or args.install_only or args.config_dir): + raise ProxyError("--yes нельзя совмещать с --root, --install-only или --config-dir") + if (args.profile_file or args.port is not None) and (args.root or args.install_only or args.config_dir): + raise ProxyError("--file и --port относятся к настройке; их нельзя совмещать " + "с --root, --install-only или --config-dir") + if args.yes or (sys.stdin.isatty() and not args.root and not args.install_only + and not args.config_dir): + return setup_service(args) + if not (args.root or args.install_only or args.config_dir): + raise ProxyError("без терминала выберите --yes для полного перехода " + "или --install-only для установки только файлов") + return service_install(args) + + def service_install(args): - source = Path(args.config_dir) - xray = Path(args.xray) + if args.config_dir and args.profile != "default": + raise ProxyError("выберите --profile или --config-dir, не оба сразу") root = Path(args.root) if args.root else Path("/") + if not root.is_absolute(): + raise ProxyError("root должен быть абсолютным путём") + if args.root and (args.enable or args.start or args.restart): + raise ProxyError("--root несовместим с --enable, --start и --restart") + home = Path.home() + if os.geteuid() == 0 and "SUDO_UID" in os.environ and (not args.config_dir or not args.xray): + import pwd + try: + home = Path(pwd.getpwuid(int(os.environ["SUDO_UID"])).pw_dir) + except (ValueError, KeyError): + raise ProxyError("не удалось определить пользователя sudo; укажите --config-dir и --xray") + source = Path(args.config_dir) if args.config_dir else user_context(args.profile, home=home).config_dir + xray = find_service_xray(args.xray, home, root) + if xray is None: + raise ProxyError("Xray не найден; запустите proxy-client service install в терминале " + "для настройки или выполните proxy-client install") if not all(path.is_absolute() for path in (source, xray, root)): raise ProxyError("config-dir, xray и root должны быть абсолютными путями") - if args.root and (args.enable or args.start): - raise ProxyError("--root несовместим с --enable и --start") + if not service_is_installed(root): + validate_service_source(source) if not args.root and os.geteuid() != 0: - raise ProxyError("системная установка требует root") + # Пути выбираем до sudo: оно сбрасывает HOME, XDG и PATH. + command = ["sudo", "--", sys.executable, str(Path(__file__).resolve()), + "service", "install", "--config-dir", str(source), "--xray", str(xray)] + if args.enable: + command.append("--enable") + if args.start: + command.append("--start") + if args.restart: + command.append("--restart") + if args.expect_profile: + command.append("--expect-profile") + print("Установка системной службы через sudo.", flush=True) + return exit_code(subprocess.run(command, check=False).returncode) settings = root / "etc/proxy-client" # При обновлении служба уже имеет собственные настройки вне HOME. - if not (settings / "config").exists(): - config = parse_config((source / "config").read_text(encoding="utf-8")) - 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", user_context()) + config_path = settings / "config" if (settings / "config").exists() else source / "config" + config = service_config(config_path.read_text(encoding="utf-8")) + profile_path = settings / "default.url" if (settings / "default.url").exists() else source / "default.url" + profile_context = service_context(argparse.Namespace(config_dir=config_path.parent, + runtime_dir=root / "run/proxy-client", xray=xray)) if profile_path.parent == settings else user_context() + profile = load_profile(profile_path, profile_context) + if args.expect_profile: + expected_config = validate_service_source(source) + expected_profile = load_profile(source / "default.url", user_context()) + if vars(config) != vars(expected_config) or build_xray_config(profile, config.address, config.port) != \ + build_xray_config(expected_profile, expected_config.address, expected_config.port): + raise ProxyError("системные настройки отличаются от снимка перехода; они оставлены без изменений. " + "Для их смены отредактируйте /etc/proxy-client отдельно") check_xray_version(str(xray)) + with tempfile.TemporaryDirectory(prefix="proxy-service-test-") as temporary: + generated = Path(temporary) / "client.json" + write_private_json(generated, build_xray_config(profile, config.address, config.port)) + run_xray_test(str(xray), generated) files = { "usr/local/lib/proxy-client/proxy-client": (Path(__file__).read_bytes(), 0o755), "usr/local/lib/proxy-client/xray": (xray.read_bytes(), 0o755), @@ -1166,7 +1537,13 @@ def service_install(args): subprocess.run(["systemctl", "enable", "proxy-client.service"], check=True) if args.start: subprocess.run(["systemctl", "start", "proxy-client.service"], check=True) - print("Служба установлена. Конфигурация сохранена; автоматического перезапуска нет.") + if args.restart: + subprocess.run(["systemctl", "stop", "proxy-client.service"], check=True) + check_port_available(ipaddress.ip_address(config.address), config.port) + subprocess.run(["systemctl", "start", "proxy-client.service"], check=True) + if not wait_until(lambda: service_ready(config), 10): + raise ProxyError("служба не стала готова; проверьте sudo journalctl -u proxy-client -b") + print("Файлы службы установлены.") report_journal_storage(root) return 0 @@ -1185,11 +1562,19 @@ def build_parser(): serve.add_argument("--runtime-dir", default="/run/proxy-client") serve.add_argument("--xray", default="/usr/local/lib/proxy-client/xray") setup = actions.add_parser("install", help="установить системную службу") - setup.add_argument("--config-dir", required=True) - setup.add_argument("--xray", required=True) + setup.add_argument("--config-dir", help="каталог настроек (по умолчанию текущий профиль)") + setup.add_argument("--xray", help="готовый Xray (по умолчанию ~/.local/bin/xray или PATH)") setup.add_argument("--root", help="изолированный корень без systemctl") setup.add_argument("--enable", action="store_true") setup.add_argument("--start", action="store_true") + setup.add_argument("--restart", action="store_true", help="перезапустить и проверить готовность службы") + setup.add_argument("--yes", "--auto-yes", action="store_true", + help="принять план полного перехода без вопроса") + setup.add_argument("--install-only", action="store_true", + help="установить файлы без мастера и переключения домашнего профиля") + setup.add_argument("-f", "--file", dest="profile_file", help="закрытый файл VLESS-ссылки для настройки") + setup.add_argument("--port", type=int, help="локальный порт при настройке (по умолчанию 12334)") + setup.add_argument("--expect-profile", action="store_true", help=argparse.SUPPRESS) install_parser = subparsers.add_parser("install", help="установить проверенный Xray") install_parser.add_argument( "--replace", action="store_true", help="заменить существующий ~/.local/bin/xray" @@ -1214,10 +1599,10 @@ def main(): try: if arguments.subcommand == "service": - if arguments.profile != "default": - raise ProxyError("service не использует пользовательские профили") if arguments.service_action == "install": - return service_install(arguments) + return command_service_install(arguments) + if arguments.profile != "default": + raise ProxyError("service run не использует пользовательские профили") context = service_context(arguments) return command_start(load_config(context), None, context) context = user_context(arguments.profile) @@ -1234,6 +1619,9 @@ def main(): return command_run(config, arguments.arguments, context) if arguments.subcommand == "start": return command_start(config, arguments.profile_file, context) + except (EOFError, KeyboardInterrupt): + sys.stderr.write("Установка или команда прервана пользователем.\n") + return 130 except (ProxyError, OSError, ValueError, subprocess.CalledProcessError) as error: sys.stderr.write("Ошибка: {}\n".format(error)) return 1 diff --git a/proxy-client/README.md b/proxy-client/README.md index e85f443..617d1af 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -330,20 +330,116 @@ Linux, до входа пользователя. Требуются systemd, `sy администратора для установки. Служба одна; именованные профили остаются пользовательскими. -Сначала подготовьте и проверьте профиль Xray обычным ручным запуском. Перед -запуском службы остановите ручной экземпляр, если он занимает тот же порт. -Установите службу, указав каталог настроек и уже установленный Xray: +### Установка и переход из tmux + +Запустите от своего пользователя, без `sudo`: ```bash -sudo "$HOME/.local/bin/proxy-client" service install \ - --config-dir "$HOME/.config/proxy-client" \ - --xray "$HOME/.local/bin/xray" --enable --start +proxy-client service install ``` -В качестве `--config-dir` можно указать каталог именованного профиля. -Пути должны быть абсолютными. `--enable` включает запуск при загрузке, -`--start` запускает сейчас. Без этих параметров команда только устанавливает -файлы и регистрирует службу. +В терминале команда показывает план и просит одно подтверждение. +Если настроек нет, она запросит VLESS-ссылку скрытым вводом и предложит +локальный порт `12334`. Если Xray отсутствует, скачает закреплённую версию +и проверит контрольную сумму. Ссылка не попадает в аргументы процессов и журнал. + +Если профиль уже настроен, используются его `config` и `default.url`. +После подтверждения команда проверяет конфигурацию Xray, устанавливает +системные файлы через `sudo`, останавливает свой ручной Xray и запускает службу +с автозапуском. Текущие соединения при переходе прервутся. Чужой процесс, +занимающий порт, команда не останавливает. + +Когда служба активна и локальный порт доступен, домашний `config` переключается +на `type=external`. Поэтому `proxy_on`, `status` и `run` сразу работают со +службой. Прежний файл сохраняется рядом как `config.before-service.*`. +Готовность локального порта не гарантирует доступность удалённого сервера. + +Если файлы службы уже установлены через `--install-only`, но домашний профиль +ещё имеет `type=xray`, мастер завершит перенос. Сначала он проверит совпадение +настроек с системными; другой профиль не заменит уже настроенную службу. + +Для именованного профиля: + +```bash +proxy-client --profile work service install +``` + +Для принятия плана без вопроса есть `--yes` и его синоним `--auto-yes`: + +```bash +proxy-client service install --yes +``` + +При настройке с нуля без терминала передайте закрытый файл со ссылкой: + +```bash +chmod 600 ~/connection.url +proxy-client service install --auto-yes --file ~/connection.url --port 12334 +``` + +`--file` также подходит, если ручной Xray запускался с разовой ссылкой +через `start -f`. Без него берётся сохранённый `default.url`. +`--yes` принимает план, но не заменяет ввод недостающей ссылки и не отменяет +проверки или запрос пароля `sudo`. + +### Ошибка или прерывание + +До успешной установки системных файлов ручной Xray продолжает работать. +Если запуск службы не удался после его остановки, домашний профиль остаётся +прежним. Команда пытается отключить неудачно запущенную службу и показывает, +как вернуть ручной запуск. Восстановить прежние соединения и процесс в tmux +автоматически нельзя. + +Повторите `proxy-client service install`: незавершённый переход продолжится +по закрытому снимку `service-setup.json` в каталоге состояния выбранного +профиля. Снимок содержит VLESS-ссылку, имеет права `600` в каталоге `700` +и удаляется после успеха. Если домашние или системные настройки успели +измениться, команда остановится, чтобы их не затереть. Проверяется также +исходный `default.url` или файл, переданный через `--file`, до остановки +ручного Xray. После запуска служба использует свою копию ссылки; позднее +изменение исходной ссылки не отменяет завершение перехода. + +Чтобы отказаться от старого снимка и прочитать текущие настройки заново, +удалите только `service-setup.json`: точную команду `rm -- …` CLI печатает +при конфликте. Для профиля по умолчанию без переопределения XDG это: + +```bash +rm -- ~/.local/state/proxy-client/service-setup.json +proxy-client service install +``` + +Сброс снимка не меняет домашние файлы, настройки службы и работающие процессы. +Если системные настройки уже отличаются от желаемых, их нужно изменить +отдельно в `/etc/proxy-client` — мастер их не перезаписывает. + +### Только установка файлов + +Для прежнего режима без мастера и переключения домашнего профиля: + +```bash +proxy-client service install --install-only +``` + +Без терминала явно выберите `--yes` для полного перехода или `--install-only` +для установки файлов; команда без выбора режима завершится с подсказкой. +Режим установки файлов требует готовые +настройки и Xray, а при их отсутствии выдаёт подсказку до вызова `sudo`. +`--enable`, `--start` и `--restart` явно включают автозапуск, запуск сейчас +или перезапуск с проверкой готовности. Без этих флагов служба не запускается. + +Xray ищется в `~/.local/bin`, затем в `PATH` и системной установке. +Параметр `--xray FILE` переопределяет выбор. Для установки из произвольного +каталога есть `--config-dir DIR`: он тоже выбирает режим установки файлов. +Пути должны быть абсолютными; `--config-dir` несовместим с именованным +`--profile`. Прямой запуск через `sudo` подходит для `--install-only`; +нестандартный каталог XDG в этом случае задавайте через `--config-dir`. + +### Доступ к прокси + +Xray слушает только числовой loopback-адрес: `127.0.0.1` или `::1`. +Проверка выполняется при установке и каждом запуске. Адреса всех интерфейсов +(`0.0.0.0`, `::`) и локальной сети не принимаются. Другие процессы на этой же +машине могут пользоваться прокси. Установщик копирует настройки в `/etc/proxy-client/`, а CLI и Xray — в `/usr/local/lib/proxy-client/`. Служба больше не зависит от домашнего каталога. @@ -381,18 +477,20 @@ sudo systemctl disable --now proxy-client sudo systemctl enable --now proxy-client ``` -При обновлении повторите установку из актуальной копии пакета. Существующие -системные настройки сохраняются; исходный домашний профиль уже не нужен: +При обновлении повторите команду из актуальной копии пакета: ```bash -sudo "$HOME/.local/bin/proxy-client" service install \ - --config-dir /etc/proxy-client --xray "$HOME/.local/bin/xray" -sudo systemctl restart proxy-client +proxy-client service install ``` -Работающий процесс сам не перезапускается. Чужие или вручную изменённые -исполняемые файлы и unit-файл установщик не заменяет; изменения параметров -systemd делайте через `sudo systemctl edit proxy-client`. Файлы заменяются +Мастер предложит обновить и перезапустить службу. Настройки в +`/etc/proxy-client` сохраняются, домашний профиль не меняется и может +отсутствовать. Без терминала используйте `--yes` для этого же действия. +Режим `--install-only` по-прежнему не перезапускает службу без явного флага. + +Чужие или вручную изменённые исполняемые файлы и unit-файл установщик +не заменяет; изменения параметров systemd делайте через +`sudo systemctl edit proxy-client`. Файлы заменяются по одному атомарно, общего отката всей установки при сбое нет. После устранения причины сбоя повторите установку: журнал незавершённой установки позволяет продолжить её, сохраняя проверку принадлежности файлов. diff --git a/tests/test_proxy_profiles_service.py b/tests/test_proxy_profiles_service.py index 16b2a1d..ecd448d 100644 --- a/tests/test_proxy_profiles_service.py +++ b/tests/test_proxy_profiles_service.py @@ -1,6 +1,8 @@ """Публичный CLI профилей и установка в изолированный корень.""" import os +import json import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -20,7 +22,7 @@ class ProfilesServiceTest(unittest.TestCase): def cli(self, *args): return subprocess.run([str(CLI), *map(str, args)], env=self.env, - capture_output=True, text=True, timeout=15) + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15) def test_unknown_profile_never_falls_back_even_for_stop(self): for command in ('status', 'env', 'run', 'start', 'stop'): @@ -66,6 +68,104 @@ class ProfilesServiceTest(unittest.TestCase): self.assertEqual((stage / 'etc/proxy-client/default.url').stat().st_mode & 0o777, 0o640) self.assertIn('journald', result.stdout) + def test_install_defaults_copy_current_profile_and_local_xray(self): + args = self.install_args() + source, xray = args[3], args[5] + for name in ('config', 'default.url'): + (source / name).replace(self.config / name) + local_xray = self.root / '.local/bin/xray' + local_xray.parent.mkdir(parents=True) + xray.replace(local_xray) + result = self.cli('service', 'install', '--root', self.root / 'stage') + self.assertEqual(0, result.returncode, result.stderr) + settings = self.root / 'stage/etc/proxy-client' + for name in ('config', 'default.url'): + self.assertEqual((self.config / name).read_bytes(), (settings / name).read_bytes()) + self.assertEqual(local_xray.read_bytes(), + (self.root / 'stage/usr/local/lib/proxy-client/xray').read_bytes()) + + def test_missing_settings_fail_before_sudo(self): + args = self.install_args() + fake = self.root / 'fake-os' + fake.mkdir() + (fake / 'sitecustomize.py').write_text('import os\nos.geteuid = lambda: 1000\n') + log = self.root / 'sudo-called' + sudo = fake / 'sudo' + sudo.write_text('#!/bin/sh\ntouch "$TEST_SUDO_LOG"\nexit 23\n') + sudo.chmod(0o755) + self.env.update(PYTHONPATH=str(fake), TEST_SUDO_LOG=str(log), + PATH=str(fake) + os.pathsep + os.environ['PATH']) + result = self.cli('service', 'install', '--install-only', '--xray', args[5]) + self.assertEqual(1, result.returncode, result.stderr) + self.assertIn('настрой', result.stderr) + self.assertIn('терминал', result.stderr) + self.assertNotIn('No such file', result.stderr) + self.assertFalse(log.exists()) + + def test_install_can_select_named_profile(self): + args = self.install_args() + named = self.config / 'profiles/work' + named.parent.mkdir(parents=True) + args[3].rename(named) + result = self.cli('--profile', 'work', 'service', 'install', '--xray', args[5], + '--root', self.root / 'stage') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual((named / 'config').read_bytes(), + (self.root / 'stage/etc/proxy-client/config').read_bytes()) + ambiguous = self.cli('--profile', 'work', *args) + self.assertEqual(1, ambiguous.returncode) + self.assertIn('--config-dir', ambiguous.stderr) + + def test_install_resolves_user_paths_before_sudo(self): + args = self.install_args() + for name in ('config', 'default.url'): + (args[3] / name).replace(self.config / name) + local_xray = self.root / '.local/bin/xray' + local_xray.parent.mkdir(parents=True) + args[5].replace(local_xray) + fake = self.root / 'fake-os' + fake.mkdir() + (fake / 'sitecustomize.py').write_text('import os\nos.geteuid = lambda: 1000\n') + sudo = fake / 'sudo' + sudo.write_text('#!/usr/bin/env python3\nimport json, os, sys\n' + 'open(os.environ["TEST_SUDO_ARGS"], "w").write(json.dumps(sys.argv[1:]))\n' + 'sys.exit(23)\n') + sudo.chmod(0o755) + log = self.root / 'sudo-args.json' + self.env.update(PYTHONPATH=str(fake), TEST_SUDO_ARGS=str(log), + PATH=str(fake) + os.pathsep + os.environ['PATH']) + result = self.cli('service', 'install', '--install-only', '--enable', '--start') + self.assertEqual(23, result.returncode, result.stderr) + command = json.loads(log.read_text()) + self.assertEqual(['service', 'install', '--config-dir', str(self.config), + '--xray', str(local_xray), '--enable', '--start'], command[3:]) + self.assertEqual('--', command[0]) + self.assertEqual(str(CLI.resolve()), command[2]) + + def test_direct_sudo_uses_invoking_users_home(self): + args = self.install_args() + source = self.root / '.config/proxy-client' + source.parent.mkdir() + args[3].rename(source) + xray = self.root / '.local/bin/xray' + xray.parent.mkdir(parents=True) + args[5].replace(xray) + fake = self.root / 'fake-os' + fake.mkdir() + (fake / 'sitecustomize.py').write_text( + 'import os, pwd, types\nos.geteuid = lambda: 0\n' + 'def user(uid):\n' + ' assert uid == 12345\n' + ' return types.SimpleNamespace(pw_dir=os.environ["TEST_USER_HOME"])\n' + 'pwd.getpwuid = user\n') + self.env.pop('XDG_CONFIG_HOME') + self.env.update(HOME=str(self.root / 'root-home'), SUDO_UID='12345', + TEST_USER_HOME=str(self.root), PYTHONPATH=str(fake)) + result = self.cli('service', 'install', '--root', self.root / 'stage') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual((source / 'config').read_bytes(), + (self.root / 'stage/etc/proxy-client/config').read_bytes()) + def test_install_refuses_foreign_files_before_writing(self): args = self.install_args() target = self.root / 'stage/etc/systemd/system/proxy-client.service' @@ -97,6 +197,26 @@ class ProfilesServiceTest(unittest.TestCase): updated = self.cli(*args) self.assertEqual(0, updated.returncode, updated.stderr) + def test_default_update_preserves_service_settings_without_home_files(self): + args = self.install_args() + first = self.cli(*args) + self.assertEqual(0, first.returncode, first.stderr) + settings = self.root / 'stage/etc/proxy-client' + saved = {name: (settings / name).read_bytes() for name in ('config', 'default.url')} + for path in args[3].iterdir(): + path.unlink() + args[3].rmdir() + args[5].unlink() + # В PATH только Python для shebang тестового CLI; Xray там отсутствует. + path = self.root / 'path' + path.mkdir() + (path / 'python3').symlink_to(sys.executable) + self.env['PATH'] = str(path) + updated = self.cli('service', 'install', '--root', self.root / 'stage') + self.assertEqual(0, updated.returncode, updated.stderr) + for name, data in saved.items(): + self.assertEqual(data, (settings / name).read_bytes()) + def test_unspecified_journal_storage_is_not_guessed(self): result = self.cli(*self.install_args()) self.assertEqual(0, result.returncode, result.stderr) @@ -153,6 +273,7 @@ import os, pathlib, grp, types stage = os.environ['TEST_INSTALL_ROOT'] OriginalPath = type(pathlib.Path()) class StagedPath: + home = staticmethod(OriginalPath.home) def __new__(cls, *parts, **kwargs): if parts == ('/',): parts = (stage,) @@ -204,9 +325,29 @@ os.chown = chown self.assertFalse((self.root / 'stage').exists()) config = self.root / 'source/config' original = config.read_text() - for invalid in (original.replace('type=xray', 'type=external'), - original.replace('127.0.0.1', '192.0.2.1')): + for invalid in [original.replace('type=xray', 'type=external')] + [ + original.replace('127.0.0.1', address) + for address in ('0.0.0.0', '::', '192.0.2.1', '2001:db8::1', 'localhost')]: config.write_text(invalid) result = self.cli(*args) self.assertNotEqual(0, result.returncode) self.assertFalse((self.root / 'stage').exists()) + + def test_manual_and_service_run_reject_non_loopback_before_start(self): + args = self.install_args() + config_path = args[3] / 'config' + original = config_path.read_text() + for name in ('config', 'default.url'): + (args[3] / name).replace(self.config / name) + runtime = self.root / 'service-runtime' + commands = [('start',), ('service', 'run', '--config-dir', self.config, + '--runtime-dir', runtime, '--xray', args[5])] + for address in ('0.0.0.0', '::', '192.0.2.1', '2001:db8::1', 'localhost'): + (self.config / 'config').write_text(original.replace('127.0.0.1', address)) + for command in commands: + with self.subTest(address=address, command=command): + result = self.cli(*command) + self.assertEqual(1, result.returncode, result.stderr) + self.assertIn('loopback', result.stderr) + self.assertFalse(runtime.exists()) + self.assertFalse((self.root / 'state').exists()) diff --git a/tests/test_proxy_service_setup.py b/tests/test_proxy_service_setup.py new file mode 100644 index 0000000..397b8e9 --- /dev/null +++ b/tests/test_proxy_service_setup.py @@ -0,0 +1,520 @@ +"""Интерактивная установка через CLI; системные действия подменены.""" +import json +import hashlib +import os +import pty +import select +import subprocess +import sys +import tempfile +import time +import unittest +import zipfile +from pathlib import Path + +CLI = Path(__file__).resolve().parents[1] / 'proxy-client/.local/bin/proxy-client' +URL = ('vless://11111111-2222-4333-8444-555555555555@cdn.example:443' + '?type=xhttp&security=tls&encryption=none&sni=cdn.example&fp=chrome&path=%2FSECRET') +CONFIG = '[proxy]\ntype=xray\nname=Test\naddress=127.0.0.1\nport=12334\nno_proxy_extra=.example\n' + + +class ServiceSetupTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.config = self.root / 'config/proxy-client' + self.config.mkdir(parents=True) + self.fake = self.root / 'bin' + self.fake.mkdir() + self.log = self.root / 'actions' + self.stage = self.root / 'system' + self.env = dict(os.environ, HOME=str(self.root / 'home'), + XDG_CONFIG_HOME=str(self.root / 'config'), + XDG_STATE_HOME=str(self.root / 'state'), + PYTHONPATH=str(self.fake), TEST_ROOT=str(self.root), + PATH=str(self.fake) + os.pathsep + os.defpath) + (self.fake / 'python3').symlink_to(sys.executable) + (self.fake / 'sitecustomize.py').write_text(''' +import os, sys, pathlib, getpass, grp, types, socket, urllib.request, io +OriginalPath = type(pathlib.Path()) +class StagedPath: + home = staticmethod(OriginalPath.home) + def __new__(cls, *parts, **kwargs): + if parts == ('/',): + parts = (os.environ['TEST_ROOT'] + '/system',) + return OriginalPath(*parts, **kwargs) +pathlib.Path = StagedPath +os.geteuid = lambda: 0 if os.environ.get('TEST_PRIVILEGED') else 1000 +sys.stdin.isatty = lambda: not os.environ.get('TEST_PRIVILEGED') and not os.environ.get('TEST_NO_TTY') +if not os.environ.get('TEST_REAL_GETPASS'): + getpass.getpass = lambda prompt: input(prompt) +grp.getgrnam = lambda name: types.SimpleNamespace(gr_gid=os.getgid()) +def chown(path, uid, gid): + assert str(path).startswith(os.environ['TEST_ROOT'] + '/system/') +os.chown = chown +class LocalSocket: + def __init__(self, *args, **kwargs): pass + def __enter__(self): return self + def __exit__(self, *args): pass + def close(self): pass + def setsockopt(self, *args): pass + def bind(self, address): + busy = os.environ.get('TEST_PORT_BUSY') + if busy == 'manual': + busy = os.path.exists(os.environ['TEST_ROOT'] + '/manual-active') + if busy: raise OSError('busy') +socket.socket = LocalSocket +def connect(*args, **kwargs): + if not os.path.exists(os.environ['TEST_ROOT'] + '/system/active'): + raise OSError('not ready') + return LocalSocket() +socket.create_connection = connect +original_readlink = os.readlink +original_read_bytes = OriginalPath.read_bytes +def readlink(path, *args, **kwargs): + if str(path) == '/proc/4321/exe': + if not os.path.exists(os.environ['TEST_ROOT'] + '/manual-active'): raise FileNotFoundError() + return os.environ['TEST_ROOT'] + '/bin/xray' + return original_readlink(path, *args, **kwargs) +def read_bytes(path): + if str(path) == '/proc/4322/cmdline': + if not os.path.exists(os.environ['TEST_ROOT'] + '/manual-active'): raise FileNotFoundError() + return b'proxy-client start' + return original_read_bytes(path) +os.readlink = readlink +OriginalPath.read_bytes = read_bytes +def kill(pid, signum): + assert not os.environ.get('TEST_PRIVILEGED'), 'нельзя останавливать пользовательский процесс от root' + assert pid == 4322, 'чужой PID' + os.unlink(os.environ['TEST_ROOT'] + '/manual-active') + with open(os.environ['TEST_ROOT'] + '/actions', 'a') as stream: + stream.write('["manual-stop"]\\n') +os.kill = kill +def urlopen(url, **kwargs): + if os.environ.get('TEST_DOWNLOAD_FAIL'): raise OSError('download failed') + with open(os.environ['TEST_ROOT'] + '/actions', 'a') as stream: + stream.write('["download"]\\n') + suffix = 'digest' if url.endswith('.dgst') else 'archive' + return io.BytesIO(open(os.environ['TEST_ROOT'] + '/' + suffix, 'rb').read()) +urllib.request.urlopen = urlopen +''') + self.executable('sudo', '''#!/usr/bin/env python3 +import json, os, sys +args = sys.argv[1:] +with open(os.environ['TEST_ROOT'] + '/actions', 'a') as stream: + stream.write(json.dumps(['sudo'] + args) + '\\n') +if os.environ.get('TEST_FAIL_SUDO'): sys.exit(23) +if args[0] == '--': args.pop(0) +os.environ['TEST_PRIVILEGED'] = '1' +os.environ['HOME'] = os.environ['TEST_ROOT'] + '/root-home' +os.environ.pop('XDG_CONFIG_HOME', None) +os.environ.pop('XDG_STATE_HOME', None) +os.execvpe(args[0], args, os.environ) +''') + self.executable('systemd-sysusers', '#!/bin/sh\nexit 0\n') + self.executable('systemctl', '''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +root = Path(os.environ['TEST_ROOT']) +with (root / 'actions').open('a') as stream: + stream.write(json.dumps(['systemctl'] + sys.argv[1:]) + '\\n') +action = sys.argv[1] +active = root / 'system/active' +if action in ('start', 'restart') or (action == 'enable' and '--now' in sys.argv): + if os.environ.get('TEST_FAIL_START'): sys.exit(1) + if not os.environ.get('TEST_NOT_READY'): active.touch() + if os.environ.get('TEST_CHANGE_LINK_AT_START'): + link = root / 'config/proxy-client/default.url' + link.write_text(link.read_text().replace('cdn.example:443', 'after-start.example:443')) +if (action == 'stop' or (action == 'disable' and '--now' in sys.argv)) and active.exists(): active.unlink() +if action == 'is-active': sys.exit(0 if active.exists() else 1) +''') + self.executable('xray', '#!/bin/sh\n' + '[ "$1" != version ] || echo "Xray 26.10.10 (fake)"\nexit 0\n') + + def executable(self, name, text): + path = self.fake / name + path.write_text(text) + path.chmod(0o755) + + def ready_profile(self): + (self.config / 'config').write_text(CONFIG) + (self.config / 'default.url').write_text(URL + '\n') + (self.config / 'default.url').chmod(0o600) + + def running_profile(self): + self.ready_profile() + state = self.root / 'state/proxy-client' + state.mkdir(parents=True, exist_ok=True) + (state / 'state.json').write_text(json.dumps({ + 'supervisor_pid': 4322, 'xray_pid': 4321, 'xray_path': str(self.fake / 'xray'), + 'address': '127.0.0.1', 'port': 12334})) + (self.root / 'manual-active').touch() + + def cli(self, answers='', *args): + return subprocess.run([str(CLI), 'service', 'install', *args], input=answers, + env=self.env, capture_output=True, text=True, timeout=20) + + def test_declining_plan_changes_nothing(self): + self.ready_profile() + result = self.cli('n\n') + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('отменена', result.stdout) + self.assertFalse(self.log.exists()) + self.assertFalse(self.stage.exists()) + self.assertEqual(CONFIG, (self.config / 'config').read_text()) + + def test_fresh_setup_collects_settings_without_writing_before_confirmation(self): + result = self.cli(URL + '\n\nn\n') + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('12334', result.stdout) + self.assertIn('отменена', result.stdout) + self.assertNotIn(URL, result.stdout + result.stderr) + self.assertFalse(self.log.exists()) + self.assertEqual([], list(self.config.iterdir())) + + def test_dangling_profile_symlink_is_reported_before_secret_input(self): + (self.config / 'config').write_text(CONFIG) + (self.config / 'default.url').symlink_to(self.root / 'missing.url') + result = self.cli(URL + '\ny\n') + self.assertEqual(1, result.returncode) + self.assertIn('битый симлинк', result.stderr) + self.assertNotIn('ввод скрыт', result.stdout) + self.assertNotIn('rm -- ', result.stderr) + self.assertFalse(self.log.exists()) + self.assertFalse((self.root / 'state/proxy-client/service-setup.json').exists()) + + def test_fresh_setup_installs_starts_and_configures_shell_profile(self): + result = self.cli(URL + '\n\ny\n') + self.assertEqual(0, result.returncode, result.stderr) + settings = self.stage / 'etc/proxy-client' + self.assertIn('type = xray', (settings / 'config').read_text()) + self.assertEqual(URL + '\n', (settings / 'default.url').read_text()) + self.assertEqual(0o640, (settings / 'default.url').stat().st_mode & 0o777) + home_config = (self.config / 'config').read_text() + self.assertIn('type = external', home_config) + self.assertIn('address = 127.0.0.1', home_config) + self.assertIn('port = 12334', home_config) + self.assertTrue((self.stage / 'active').exists()) + self.assertNotIn(URL, result.stdout + result.stderr + self.log.read_text()) + + def test_readiness_check_does_not_request_sudo(self): + self.ready_profile() + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + actions = [json.loads(line) for line in self.log.read_text().splitlines()] + self.assertIn(['systemctl', 'is-active', '--quiet', 'proxy-client.service'], actions) + self.assertFalse(any(action[0] == 'sudo' and 'is-active' in action for action in actions)) + + def test_yes_accepts_file_without_a_terminal(self): + self.env['TEST_NO_TTY'] = '1' + profile = self.root / 'connection.url' + profile.write_text(URL + '\n') + profile.chmod(0o600) + result = self.cli('', '--auto-yes', '--file', str(profile), '--port', '12335') + self.assertEqual(0, result.returncode, result.stderr) + self.assertNotIn('Продолжить?', result.stdout) + self.assertIn('port = 12335', (self.config / 'config').read_text()) + self.assertNotIn(URL, result.stdout + result.stderr + self.log.read_text()) + + def test_no_terminal_requires_an_explicit_installation_mode(self): + self.running_profile() + self.env['TEST_NO_TTY'] = '1' + result = self.cli('') + self.assertEqual(1, result.returncode) + self.assertIn('--yes', result.stderr) + self.assertIn('--install-only', result.stderr) + self.assertFalse(self.log.exists()) + self.assertFalse(self.stage.exists()) + self.assertTrue((self.root / 'manual-active').exists()) + + def test_update_keeps_system_settings_without_home_profile(self): + self.ready_profile() + first = self.cli('y\n') + self.assertEqual(0, first.returncode, first.stderr) + settings = self.stage / 'etc/proxy-client' + before = {name: (settings / name).read_bytes() for name in ('config', 'default.url')} + for path in self.config.iterdir(): + path.unlink() + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('обнов', result.stdout.lower()) + for name, content in before.items(): + self.assertEqual(content, (settings / name).read_bytes()) + self.assertFalse((self.config / 'config').exists()) + + def test_migration_stops_only_own_process_after_files_are_installed(self): + self.running_profile() + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertFalse((self.root / 'manual-active').exists()) + actions = [json.loads(line) for line in self.log.read_text().splitlines()] + self.assertLess(actions.index(['systemctl', 'daemon-reload']), actions.index(['manual-stop'])) + self.assertLess(actions.index(['manual-stop']), + actions.index(['systemctl', 'enable', '--now', 'proxy-client.service'])) + backups = list(self.config.glob('config.before-service.*')) + self.assertEqual(1, len(backups)) + self.assertEqual(CONFIG, backups[0].read_text()) + self.assertIn('type = external', (self.config / 'config').read_text()) + + def test_install_only_can_be_completed_by_the_wizard(self): + self.running_profile() + first = self.cli('', '--install-only') + self.assertEqual(0, first.returncode, first.stderr) + self.env['TEST_PORT_BUSY'] = 'manual' + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertFalse((self.root / 'manual-active').exists()) + self.assertTrue((self.stage / 'active').exists()) + self.assertIn('type = external', (self.config / 'config').read_text()) + + def test_installed_different_profile_is_not_replaced_or_stopped(self): + self.running_profile() + first = self.cli('', '--install-only') + self.assertEqual(0, first.returncode, first.stderr) + system_config = self.stage / 'etc/proxy-client/config' + changed = system_config.read_text().replace('12334', '12335') + system_config.write_text(changed) + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode, result.stderr) + self.assertTrue((self.root / 'manual-active').exists()) + self.assertEqual(CONFIG, (self.config / 'config').read_text()) + self.assertEqual(changed, system_config.read_text()) + self.assertNotIn('["systemctl", "stop"', self.log.read_text()) + snapshot = self.root / 'state/proxy-client/service-setup.json' + self.assertTrue(snapshot.exists()) + self.assertIn('rm -- ' + str(snapshot), result.stderr) + self.assertIn('/etc/proxy-client', result.stderr) + + def test_start_failure_preserves_home_and_explains_recovery(self): + self.running_profile() + self.env['TEST_FAIL_START'] = '1' + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode) + self.assertEqual(CONFIG, (self.config / 'config').read_text()) + self.assertIn('proxy-client start', result.stderr) + self.assertIn('disable', self.log.read_text()) + self.assertEqual([], list(self.config.glob('config.before-service.*'))) + + def test_missing_xray_is_downloaded_and_verified_before_installation(self): + binary = (self.fake / 'xray').read_bytes() + (self.fake / 'xray').unlink() + with zipfile.ZipFile(str(self.root / 'archive'), 'w') as archive: + archive.writestr('xray', binary) + digest = hashlib.sha256((self.root / 'archive').read_bytes()).hexdigest() + (self.root / 'digest').write_text('SHA2-256= ' + digest + '\n') + self.ready_profile() + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(binary, (self.stage / 'usr/local/lib/proxy-client/xray').read_bytes()) + actions = [json.loads(line) for line in self.log.read_text().splitlines()] + self.assertEqual(['download'], actions[0]) + self.assertFalse((self.root / 'home/.local/bin/xray').exists()) + + def test_retry_after_failed_start_completes_the_transition(self): + self.running_profile() + self.env['TEST_FAIL_START'] = '1' + self.assertEqual(1, self.cli('', '--yes').returncode) + self.env.pop('TEST_FAIL_START') + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('type = external', (self.config / 'config').read_text()) + + def test_file_option_cannot_be_silently_ignored_in_install_only_mode(self): + self.ready_profile() + result = self.cli('', '--install-only', '--file', str(self.config / 'default.url')) + self.assertEqual(1, result.returncode) + self.assertIn('--file', result.stderr) + self.assertFalse(self.log.exists()) + + def test_update_downloads_missing_binary_without_asking_for_profile(self): + self.ready_profile() + self.assertEqual(0, self.cli('', '--yes').returncode) + binary = (self.fake / 'xray').read_bytes() + (self.fake / 'xray').unlink() + (self.stage / 'usr/local/lib/proxy-client/xray').unlink() + with zipfile.ZipFile(str(self.root / 'archive'), 'w') as archive: + archive.writestr('xray', binary) + digest = hashlib.sha256((self.root / 'archive').read_bytes()).hexdigest() + (self.root / 'digest').write_text('SHA2-256= ' + digest + '\n') + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(binary, (self.stage / 'usr/local/lib/proxy-client/xray').read_bytes()) + + def test_preparation_failures_do_not_stop_manual_process(self): + self.running_profile() + self.env['TEST_FAIL_SUDO'] = '1' + failed = self.cli('', '--yes') + self.assertEqual(23, failed.returncode, failed.stderr) + self.assertTrue((self.root / 'manual-active').exists()) + self.assertEqual(CONFIG, (self.config / 'config').read_text()) + pending = self.root / 'state/proxy-client/service-setup.json' + self.assertEqual(0o600, pending.stat().st_mode & 0o777) + self.env.pop('TEST_FAIL_SUDO') + (self.fake / 'xray').unlink() + self.env['TEST_DOWNLOAD_FAIL'] = '1' + failed = self.cli('', '--yes') + self.assertEqual(1, failed.returncode) + self.assertTrue((self.root / 'manual-active').exists()) + self.assertFalse(self.stage.exists()) + + def test_retry_from_scratch_uses_private_snapshot_without_new_secret_input(self): + self.env['TEST_FAIL_START'] = '1' + failed = self.cli(URL + '\n\ny\n') + self.assertEqual(1, failed.returncode, failed.stderr) + self.assertFalse((self.config / 'config').exists()) + self.env.pop('TEST_FAIL_START') + self.env['TEST_NO_TTY'] = '1' + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('type = external', (self.config / 'config').read_text()) + self.assertFalse((self.root / 'state/proxy-client/service-setup.json').exists()) + + def test_changed_link_blocks_stale_snapshot_and_reset_uses_new_settings(self): + self.ready_profile() + self.env['TEST_FAIL_SUDO'] = '1' + self.assertEqual(23, self.cli('', '--yes').returncode) + self.env.pop('TEST_FAIL_SUDO') + changed = URL.replace('cdn.example:443', 'new.example:443') + (self.config / 'default.url').write_text(changed + '\n') + before = self.log.read_bytes() + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode, result.stderr) + self.assertIn('ссылки изменился', result.stderr) + snapshot = self.root / 'state/proxy-client/service-setup.json' + self.assertIn('rm -- ' + str(snapshot), result.stderr) + self.assertFalse(self.stage.exists()) + self.assertEqual(before, self.log.read_bytes()) + snapshot.unlink() # Пользователь явно сбросил снимок по подсказке CLI. + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(changed + '\n', (self.stage / 'etc/proxy-client/default.url').read_text()) + + def test_link_edit_after_service_start_does_not_abort_the_confirmed_transition(self): + self.running_profile() + self.env['TEST_CHANGE_LINK_AT_START'] = '1' + result = self.cli('', '--yes') + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('type = external', (self.config / 'config').read_text()) + self.assertIn('after-start.example', (self.config / 'default.url').read_text()) + self.assertEqual(URL + '\n', (self.stage / 'etc/proxy-client/default.url').read_text()) + self.assertFalse((self.root / 'state/proxy-client/service-setup.json').exists()) + + def test_pending_override_explains_how_to_reset_the_snapshot(self): + self.ready_profile() + self.env['TEST_FAIL_SUDO'] = '1' + self.assertEqual(23, self.cli('', '--yes').returncode) + result = self.cli('', '--yes', '--port', '12335') + self.assertEqual(1, result.returncode) + self.assertIn('rm -- ', result.stderr) + + def test_retry_refuses_changed_system_settings_before_stopping_manual_process(self): + self.running_profile() + self.env['TEST_FAIL_START'] = '1' + self.assertEqual(1, self.cli('', '--yes').returncode) + self.env.pop('TEST_FAIL_START') + self.running_profile() + config = self.stage / 'etc/proxy-client/config' + changed = config.read_text().replace('12334', '12335') + config.write_text(changed) + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode) + self.assertIn('отличаются от снимка', result.stderr) + self.assertTrue((self.root / 'manual-active').exists()) + self.assertEqual(changed, config.read_text()) + self.assertEqual(CONFIG, (self.config / 'config').read_text()) + + def test_bad_url_and_invalid_port_do_not_install(self): + result = self.cli('not-a-vless-link\n') + self.assertEqual(1, result.returncode) + self.assertNotIn('not-a-vless-link', result.stdout + result.stderr) + result = self.cli('', '--yes', '--port', '0') + self.assertEqual(1, result.returncode) + self.assertFalse(self.log.exists()) + self.assertFalse(self.stage.exists()) + + def test_foreign_port_is_never_stopped(self): + self.ready_profile() + self.env['TEST_PORT_BUSY'] = '1' + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode) + self.assertIn('занят', result.stderr) + self.assertFalse(self.log.exists()) + self.assertEqual(CONFIG, (self.config / 'config').read_text()) + + def test_corrupt_transition_snapshot_gets_clear_error(self): + self.ready_profile() + pending = self.root / 'state/proxy-client/service-setup.json' + pending.parent.mkdir(parents=True) + pending.write_text('{}') + pending.chmod(0o600) + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode) + self.assertIn('снимок', result.stderr) + self.assertNotIn('Traceback', result.stderr) + self.assertFalse(self.log.exists()) + + def test_retry_does_not_accept_a_foreign_listener_as_the_service(self): + self.ready_profile() + self.env['TEST_FAIL_START'] = '1' + self.assertEqual(1, self.cli('', '--yes').returncode) + self.env.pop('TEST_FAIL_START') + self.env['TEST_PORT_BUSY'] = '1' + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode) + self.assertEqual(CONFIG, (self.config / 'config').read_text()) + + def test_malformed_config_does_not_expose_its_contents(self): + (self.config / 'config').write_text('PRIVATE_SENTINEL_not_ini\n') + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode) + self.assertNotIn('PRIVATE_SENTINEL', result.stdout + result.stderr) + self.assertNotIn('Traceback', result.stderr) + self.assertFalse(self.log.exists()) + + def test_retry_keeps_custom_file_in_manual_recovery_command(self): + self.running_profile() + custom = self.root / 'custom.url' + (self.config / 'default.url').rename(custom) + self.env['TEST_FAIL_START'] = '1' + self.assertEqual(1, self.cli('', '--yes', '--file', str(custom)).returncode) + self.running_profile() + (self.config / 'default.url').unlink() + result = self.cli('', '--yes') + self.assertEqual(1, result.returncode) + self.assertIn('proxy-client start -f ' + str(custom), result.stderr) + + def test_secret_input_is_not_echoed_in_a_real_terminal(self): + master, slave = pty.openpty() + self.addCleanup(os.close, master) + process = subprocess.Popen([str(CLI), 'service', 'install'], stdin=slave, + stdout=slave, stderr=slave, start_new_session=True, + env=dict(self.env, TEST_REAL_GETPASS='1')) + os.close(slave) + output = bytearray() + def until(prompt): + deadline = time.monotonic() + 10 + while prompt.encode() not in output: + if time.monotonic() > deadline: + self.fail('не появился запрос ввода: ' + prompt) + if select.select([master], [], [], 0.1)[0]: + output.extend(os.read(master, 4096)) + try: + until('ввод скрыт') + os.write(master, (URL + '\n').encode()) + until('Локальный порт') + os.write(master, b'\n') + until('Продолжить?') + os.write(master, b'n\n') + self.assertEqual(0, process.wait(timeout=10)) + self.assertNotIn(URL.encode(), output) + self.assertFalse(self.log.exists()) + finally: + if process.poll() is None: + process.kill() + process.wait() + + +if __name__ == '__main__': + unittest.main()