diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..396af70 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,10 @@ +# Термины dotfiles + +## Прокси + +**Профиль прокси** — именованный набор настроек одного прокси: его тип, +адрес и порт для приложений, исключения и, для управляемого Xray, VLESS-ссылка. +Не следует называть профилем только VLESS-ссылку. + +**VLESS-ссылка** — параметры подключения к удалённому серверу VLESS. +Она не задаёт локальный порт прокси и исключения для приложений. diff --git a/docs/specs/2026-07-15-proxy-client-design.md b/docs/specs/2026-07-15-proxy-client-design.md index 19b6221..9d25ecb 100644 --- a/docs/specs/2026-07-15-proxy-client-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -4,6 +4,9 @@ Статус: реализовано; 2026-09-28 упрощено по принципу ниже +Расширение от 2026-11-02: +[пользовательские профили и системная служба](2026-11-02-proxy-client-profiles-service-design.md). + Точное поведение, форматы, сообщения и команды описаны в коде, `proxy-client --help` и [`proxy-client/README.md`](../../proxy-client/README.md). Здесь — то, чего по коду не видно: задача, границы, выбор и его причины, риски. 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 new file mode 100644 index 0000000..1d620cc --- /dev/null +++ b/docs/specs/2026-11-02-proxy-client-profiles-service-design.md @@ -0,0 +1,173 @@ +# Профили proxy-client и системная служба + +Дата: 2026-11-02 + +Статус: реализовано; запуск после загрузки ОС и восстановление настоящей +службы systemd требуют отдельной проверки на Linux-ВМ. + +## Задача и границы + +На Linux-ВМ, компьютерах и ноутбуках нужен прокси, доступный после загрузки +без входа пользователя и ручного запуска. Приложения уже знают его адрес. +Одновременно пользователь должен иметь возможность запустить другой прокси +на другом порту в открытом терминале. + +Это расширяет [первоначальный дизайн](2026-07-15-proxy-client-design.md): +добавляет системную службу и несколько пользовательских профилей. +Остальные ограничения и принцип личного инструмента сохраняются. +Термины определены в [CONTEXT.md](../../CONTEXT.md). + +Не добавляем несколько системных экземпляров, фоновый пользовательский режим, +TUN, системную маршрутизацию, автоматический выбор прокси или поддержку Windows. +Для системного режима требуется systemd; ручной режим сохраняет независимость +от него. Пакет по-прежнему можно использовать отдельно от остальных dotfiles. + +## Пользовательские профили + +Профиль по умолчанию сохраняет нынешние файлы +`~/.config/proxy-client/config` и `default.url`. Отсутствие выбора профиля +не меняет поведения существующих команд, включая прежний внешний прокси +при отсутствии конфигурации. Учитываются существующие настройки XDG. + +Дополнительные профили размещаются в +`~/.config/proxy-client/profiles/<имя>/`, каждый со своими `config` и +`default.url`. Имя `default` обозначает существующий профиль по умолчанию. +Неизвестное имя даёт ошибку, а не переход к другому профилю. + +Один общий параметр выбора профиля действует на запуск, остановку, проверку, +вывод окружения и запуск дочерних команд. Существующий выбор файла VLESS-ссылки +через `start -f` остаётся разовым переопределением ссылки выбранного профиля. +Bash-команды без явного выбора продолжают использовать профиль по умолчанию. + +Каждый профиль получает собственные сведения о процессе, блокировку и +сгенерированный JSON. Запуск и остановка одного не затрагивают остальные. +Два экземпляра могут работать одновременно, если их локальные порты различны. +Занятый порт даёт ошибку; чужой процесс не останавливается. + +Ручной запуск остаётся на переднем плане, с выводом в терминал и остановкой +через Ctrl+C либо команду остановки выбранного профиля. Само обращение к прокси +через `run` или `env` ничего не запускает. + +## Bash-команды + +`proxy_on` без параметров выбирает профиль по умолчанию, с `--profile` — +именованный. Он меняет только окружение текущего терминала после успешной +проверки. `proxy_off` очищает переменные, но не останавливает процессы. +`myip` использует текущее окружение. + +`pclaude` и `pcodex` принимают ведущий `--proxy-profile` для выбора профиля. +Без него остаётся профиль по умолчанию; остальные аргументы и код возврата +дочерней программы сохраняются. Обычный `--profile` относится к дочерней +программе. Bash-модуль передаёт выбор CLI и не дублирует правила профилей. + +Выбор через `proxy_on` не меняет профиль по умолчанию: обычная команда +наследует окружение, а обёртка с префиксом `p` явно выбирает свой профиль. +Это различие должно быть показано в README пакета. + +## Системная служба + +Одна служба `proxy-client.service` читает один комплект настроек из +`/etc/proxy-client/`. Ей не нужны именованные профили или домашний каталог +пользователя. Исполняемые файлы устанавливаются в системное расположение, +без ссылок на пользовательский каталог исходников. Xray работает под отдельной +непривилегированной учётной записью и слушает только loopback. + +Временные файлы процесса, блокировка и сгенерированный JSON находятся в +закрытом `/run/proxy-client/`. Секреты доступны только администратору и +служебной учётной записи. Пользовательские команды остановки не управляют +системным экземпляром: за него отвечает systemd. + +Установка оформляется подкомандой `service install`, в соответствии с +существующим интерфейсом CLI. Она размещает необходимые файлы и регистрирует +службу. Запуск сейчас и автозапуск после загрузки выбираются явно; установщик +может предоставить явные параметры для совмещения этих действий с установкой. +Точное написание параметров определяется при реализации. + +Для первоначального заполнения можно скопировать выбранный пользовательский +профиль. Источник должен быть указан однозначно: повышение прав не должно +подменить его домашним каталогом root. Копия становится самостоятельной: +дальнейшие изменения домашнего профиля не меняют службу. Повторная установка +сохраняет существующие настройки и не перезапускает службу скрыто. + +Systemd отвечает за запуск, остановку и перезапуск после аварии. Явная +остановка должна оставлять службу остановленной до следующего запуска. +Если автозапуск включён, после перезагрузки она запускается снова. + +Отсутствие внешней сети при загрузке не должно приводить к окончательному +отказу службы. Готовность локального порта и доступность удалённого маршрута +различаются: автозапуск не гарантирует успешность первого запроса любого +приложения. Для приложений, не умеющих повторять запрос, порядок запуска и +ожидание готовности потребуют отдельной настройки. + +## Журналы и совместная работа + +Вывод службы направляется в journald. Отдельный текстовый журнал и собственная +ротация не нужны. Хранение между загрузками зависит от настройки journald; +установщик проверяет её и сообщает результат, не меняя глобальную политику +журналирования машины. Секреты не должны попадать в журнал. + +Если системная служба занимает порт прежнего пользовательского Xray, второй +запуск на этом порту завершается ошибкой. Чтобы использовать системный прокси +через пользовательские команды, подходит профиль типа `external`. Для второго +Xray нужен другой порт. Установка службы не меняет домашний профиль молча. + +## Устройство модулей + +Применяем `codebase-design`: выбор профиля и все его пути должны определяться +в одном месте, чтобы команды не повторяли правила размещения файлов. +Управление процессом использует уже выбранные настройки и расположение файлов. +Системная установка скрывает размещение, права и взаимодействие с systemd +за небольшим интерфейсом; не копирует разбор VLESS и управление Xray. + +Не вводим обобщённую поддержку других менеджеров служб и дополнительные +слои ради возможного будущего использования. Реально различаются +пользовательское и системное размещение, а также способ управления процессом. + +## Проверки + +Согласованы три интерфейса для новых тестов TDD: + +1. CLI пользовательских профилей: прежнее поведение без имени, выбор + именованного профиля, два одновременных процесса, независимая остановка, + отказ при занятом порте и правильное окружение дочерней команды. +2. Установка службы через CLI и управление через systemd: размещение файлов, + сохранение настроек при повторной установке, явное включение автозапуска, + запуск и остановка, восстановление после аварии и доступность журналов. + +3. Bash-команды: выбор профиля, передача аргументов и кода возврата, + сохранение окружения при ошибке и отсутствие остановки процессов. + +Тесты проверяют наблюдаемое поведение через эти интерфейсы, а не внутренние +функции и последовательности их вызовов. Идём по одному сценарию: +падающий тест, минимальная реализация, следующий сценарий. + +Локальные проверки используют временные каталоги, синтетические профили и +подставные внешние программы. Это не доказывает работу настоящего systemd. +Окончательная проверка системного режима нужна в отдельной Linux-среде +с systemd: запуск до входа пользователя после загрузки, восстановление после +аварии, явная остановка и одновременная работа ручного экземпляра. +Изменение текущей машины или её перезагрузка не входят в проверку по умолчанию. + +Локальный набор прошёл на Python 3.8 и 3.14: 67 тестов, проверки Bash-команд +и пользовательского установщика. Дополнительно проверены Bash-команды вместе +с настоящим CLI и двумя локальными тестовыми портами. Unit прошёл статическую +проверку в изолированном корне с тестовыми базовыми target-файлами. +Shellcheck в среде отсутствует. Эти результаты не заменяют проверку загрузки ОС. + +## Документация и альтернативы + +Подробные инструкции размещены в +[`proxy-client/README.md`](../../proxy-client/README.md): профили, системная +установка и обновление, управление, автозапуск, журналы и диагностика. +В корневом README достаточно существующей ссылки на пакет. Этот документ +хранит причины решений; рабочие команды и примеры живут в README пакета. + +Пользовательская служба с linger позволила бы запускать прокси до входа, +но выбрано явное разделение системных и домашних настроек. Несколько +именованных системных служб пока не нужны. Запуск в терминале сохраняется +для дополнительных прокси, но сам по себе не решает задачу загрузки машины. + +Правила systemd сверены с документацией +[каталогов службы](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=), +[перезапуска](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Restart=) +и [хранения журналов](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html#Storage=). diff --git a/proxy-client/.config/proxy-client/shell.bash b/proxy-client/.config/proxy-client/shell.bash index a8920e7..a5e0a69 100644 --- a/proxy-client/.config/proxy-client/shell.bash +++ b/proxy-client/.config/proxy-client/shell.bash @@ -32,8 +32,12 @@ _proxy_apply_env() { } proxy_on() { + if (( $# != 0 )) && { (( $# != 2 )) || [[ "$1" != --profile || -z "${2:-}" ]]; }; then + echo "Использование: proxy_on [--profile NAME]" >&2 + return 2 + fi local output - if ! output="$(_proxy_cli env)" || ! _proxy_apply_env "$output"; then + if ! output="$(_proxy_cli "$@" env)" || ! _proxy_apply_env "$output"; then echo "Ошибка: прокси не включён" >&2 return 1 fi @@ -53,7 +57,16 @@ _proxy_run() { return 2 fi shift - _proxy_cli run -- "$command_name" "$@" + local -a profile_args=() + if [[ "${1:-}" == --proxy-profile ]]; then + if (( $# < 2 )) || [[ -z "$2" ]]; then + echo "Ошибка: --proxy-profile требует имя профиля" >&2 + return 2 + fi + profile_args=(--profile "$2") + shift 2 + fi + _proxy_cli "${profile_args[@]}" run -- "$command_name" "$@" } pclaude() { _proxy_run claude "$@"; } diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index aff728f..ad1a111 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -120,15 +120,28 @@ 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") + + def config_home(): + if SYSTEM_MODE: + return SYSTEM_CONFIG root = os.environ.get("XDG_CONFIG_HOME") - return (Path(root).expanduser() if root else Path.home() / ".config") / "proxy-client" + base = (Path(root).expanduser() if root else Path.home() / ".config") / "proxy-client" + return base if SELECTED_PROFILE == "default" else base / "profiles" / SELECTED_PROFILE 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" - return base / "proxy-client" + base = base / "proxy-client" + return base if SELECTED_PROFILE == "default" else base / "profiles" / SELECTED_PROFILE def state_path(): @@ -277,7 +290,7 @@ def write_private_json(path, value): def load_profile(path): # Права проверяем у конечного файла: у симлинка они ничего не значат. resolved = path.expanduser().resolve(strict=True) - if resolved.stat().st_mode & 0o077: + if resolved.stat().st_mode & (0o037 if SYSTEM_MODE else 0o077): raise ProxyError( "файл профиля {} доступен другим пользователям; выполните: chmod 600 {}".format( resolved, shlex.quote(str(resolved)) @@ -532,7 +545,7 @@ def build_xray_config(profile, local_address, local_port): def resolve_xray(): - found = shutil.which("xray") + found = str(SYSTEM_XRAY) if SYSTEM_MODE else shutil.which("xray") if found is None: raise ProxyError("xray не найден в PATH; установите его: proxy-client install") return str(Path(found).resolve(strict=True)) @@ -907,7 +920,11 @@ def run_managed_xray(config, profile_file): # 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())]) + 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, + ) write_private_json( state_path(), { @@ -923,9 +940,15 @@ 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 работает на {}:{}; остановка: Ctrl+C или proxy-client stop".format( - config.address, config.port + "Управляемый Xray работает на {}:{}; остановка: {}".format( + config.address, config.port, stop_hint ), flush=True, ) @@ -963,12 +986,173 @@ def command_stop(): return 0 +SERVICE_UNIT = """# Managed by proxy-client +[Unit] +Description=Proxy client (Xray) +After=network.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +User=proxy-client +Group=proxy-client +RuntimeDirectory=proxy-client +RuntimeDirectoryMode=0700 +UMask=0077 +ExecStart=/usr/local/lib/proxy-client/proxy-client service run +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +StandardOutput=journal +StandardError=journal +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target +""" + + +def report_journal_storage(root): + # Приоритет каталогов соответствует journald.conf; одноимённый drop-in + # из /etc перекрывает поставляемый системой файл. + directories = ["usr/lib/systemd", "usr/local/lib/systemd", "run/systemd", "etc/systemd"] + main = next((root / directory / "journald.conf" + for directory in reversed(directories) + if (root / directory / "journald.conf").exists()), None) + dropins = {} + for directory in directories: + for path in (root / directory / "journald.conf.d").glob("*.conf"): + dropins[path.name] = path + storage = None + for path in ([main] if main else []) + [dropins[name] for name in sorted(dropins)]: + section = "" + for line in path.read_text().splitlines(): + line = line.strip() + if line.startswith("["): + section = line + elif section == "[Journal]" and re.match(r"Storage\s*=", line): + storage = line.split("=", 1)[1].strip() or None + if storage is None: + print("journald: Storage не задан; сохранение журнала зависит от сборки systemd. " + "Проверьте journalctl --list-boots после перезагрузки.") + return + persistent = storage == "persistent" or ( + storage == "auto" and (root / "var/log/journal").is_dir()) + print("journald: Storage={}; журнал {} между загрузками (по конфигурации).".format( + storage, "сохраняется" if persistent else "не сохраняется")) + + +def service_install(args): + source = Path(args.config_dir) + xray = Path(args.xray) + root = Path(args.root) if args.root else Path("/") + 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 args.root and os.geteuid() != 0: + raise ProxyError("системная установка требует root") + 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") + check_xray_version(str(xray)) + files = { + "usr/local/lib/proxy-client/proxy-client": (Path(__file__).read_bytes(), 0o755), + "usr/local/lib/proxy-client/xray": (xray.read_bytes(), 0o755), + "etc/systemd/system/proxy-client.service": (SERVICE_UNIT.encode(), 0o644), + "usr/lib/sysusers.d/proxy-client.conf": ( + b'u proxy-client - "Proxy client" /nonexistent /usr/sbin/nologin\n', 0o644), + } + manifest = root / "usr/local/lib/proxy-client/installed.json" + settings = root / "etc/proxy-client" + # До любой записи проверяем все назначения и компоненты пути. + targets = [root / name for name in files] + [ + manifest, settings, settings / "config", settings / "default.url"] + for target in targets: + if any(part.is_symlink() for part in [target, *target.parents]): + raise ProxyError("установка через симлинк запрещена: {}".format(target)) + owned = json.loads(manifest.read_text()) if manifest.exists() else {} + for name in files: + target = root / name + if target.exists() and ( + not target.is_file() or + owned.get(name) != hashlib.sha256(target.read_bytes()).hexdigest() + ): + raise ProxyError("чужой или изменённый файл: {}".format(target)) + if not manifest.exists() and settings.exists(): + raise ProxyError("чужой каталог настроек: {}".format(settings)) + for name, (data, mode) in files.items(): + target = root / name + target.parent.mkdir(parents=True, exist_ok=True) + # Атомарная замена позволяет обновить бинарник работающей службы, + # не меняя уже запущенный процесс. + descriptor, temporary = tempfile.mkstemp(prefix=".install-", dir=str(target.parent)) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + os.fchmod(stream.fileno(), mode) + os.replace(temporary, str(target)) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + settings = root / "etc/proxy-client" + settings.mkdir(parents=True, exist_ok=True) + settings.chmod(0o750) + for name in ("config", "default.url"): + target = settings / name + if not target.exists(): + descriptor = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640) + with os.fdopen(descriptor, "wb") as stream: + stream.write((source / name).read_bytes()) + os.fchmod(stream.fileno(), 0o640) + manifest.write_text(json.dumps({ + name: hashlib.sha256(data).hexdigest() for name, (data, _) in files.items() + })) + manifest.chmod(0o644) + if not args.root: + import grp + subprocess.run(["systemd-sysusers", "/usr/lib/sysusers.d/proxy-client.conf"], check=True) + gid = grp.getgrnam("proxy-client").gr_gid + for path in (settings, settings / "config", settings / "default.url"): + os.chown(str(path), 0, gid) + subprocess.run(["systemctl", "daemon-reload"], check=True) + if args.enable: + subprocess.run(["systemctl", "enable", "proxy-client.service"], check=True) + if args.start: + subprocess.run(["systemctl", "start", "proxy-client.service"], check=True) + print("Служба установлена. Конфигурация сохранена; автоматического перезапуска нет.") + report_journal_storage(root) + return 0 + + def build_parser(): parser = argparse.ArgumentParser( prog="proxy-client", description="Безопасный запуск команд через явно настроенный прокси.", ) + parser.add_argument("--profile", default="default", help="имя пользовательского профиля") subparsers = parser.add_subparsers(dest="subcommand") + service = subparsers.add_parser("service", help="системная служба") + actions = service.add_subparsers(dest="service_action", required=True) + serve = actions.add_parser("run", help="запуск из systemd") + serve.add_argument("--config-dir", default="/etc/proxy-client") + 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("--root", help="изолированный корень без systemctl") + setup.add_argument("--enable", action="store_true") + setup.add_argument("--start", action="store_true") install_parser = subparsers.add_parser("install", help="установить проверенный Xray") install_parser.add_argument( "--replace", action="store_true", help="заменить существующий ~/.local/bin/xray" @@ -984,6 +1168,7 @@ 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: @@ -992,6 +1177,25 @@ def main(): return 2 try: + if arguments.subcommand == "service": + if arguments.profile != "default": + 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)) if arguments.subcommand == "install": return command_install(arguments.replace) if arguments.subcommand == "stop": @@ -1005,7 +1209,7 @@ def main(): return command_run(config, arguments.arguments) if arguments.subcommand == "start": return command_start(config, arguments.profile_file) - except (ProxyError, OSError) as error: + except (ProxyError, OSError, ValueError, subprocess.CalledProcessError) 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 f068e4b..c0f2b68 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -1,7 +1,7 @@ # proxy-client `proxy-client` — переносимый Linux/Bash-интерфейс для запуска команд через -внешний прокси или через управляемый пользовательский Xray. Он добавляет команды +внешний прокси или через управляемый Xray. Он добавляет команды `proxy_on`, `proxy_off`, `pclaude` и `pcodex`, но не подменяет обычные `claude` и `codex`. @@ -78,7 +78,7 @@ source ~/.bash_custom ## Как выбирается прокси -Режим определяется только машинным файлом +Без выбора профиля режим определяется файлом `~/.config/proxy-client/config`: | Машинный конфиг | Используется | Что запускать | @@ -212,14 +212,55 @@ proxy-client install proxy-client start ``` -Другой закрытый профиль можно выбрать без замены `default.url`: +Другой файл VLESS-ссылки можно выбрать без замены `default.url`: ```bash proxy-client start -f ~/.config/proxy-client/work.url ``` -Для переключения профиля сначала выполните `proxy-client stop`. Все файлы с -настоящими VLESS-ссылками должны иметь права `600` и не должны попадать в Git. +Для смены ссылки работающего экземпляра сначала выполните `proxy-client stop`. +Все файлы с настоящими VLESS-ссылками должны иметь права `600` и не должны +попадать в Git. + +## Именованные профили + +Профиль объединяет настройки локального входа, исключения и, для Xray, +VLESS-ссылку. Без указания имени используется `default`: прежние `config` и +`default.url` остаются на своих местах. Дополнительные профили живут в +`~/.config/proxy-client/profiles/<имя>/`. При заданном `XDG_CONFIG_HOME` +вместо `~/.config` используется этот каталог. + +Например, создайте профиль `work`: + +```bash +mkdir -p ~/.config/proxy-client/profiles/work +cp ~/.config/proxy-client/examples/config.xray ~/.config/proxy-client/profiles/work/config +$EDITOR ~/.config/proxy-client/profiles/work/config +``` + +В `config` задайте свободный порт, например `12335`, если основной прокси +занимает `12334`. Поместите VLESS-ссылку в `profiles/work/default.url` и +ограничьте доступ: + +```bash +chmod 600 ~/.config/proxy-client/profiles/work/default.url +proxy-client --profile work start +``` + +Команда остаётся в терминале. В другом терминале можно проверить или +остановить именно этот профиль: + +```bash +proxy-client --profile work status +proxy-client --profile work stop +``` + +Параметр `--profile` ставится перед подкомандой. Неизвестный профиль даёт +ошибку; перехода на `default` нет. Разные профили могут работать одновременно +на разных портах. Остановка одного не затрагивает остальные. + +`start -f FILE` переопределяет только файл ссылки выбранного профиля на этот +запуск. Его порт и исключения по-прежнему берутся из `config`. ## Ежедневное использование @@ -243,6 +284,24 @@ proxy-client start -f ~/.config/proxy-client/work.url обёрнуты и не выполняют предварительную проверку, но после `proxy_on` наследуют прокси-переменные, как и любой другой дочерний процесс. +Для именованного профиля: + +```bash +proxy_on --profile work +pclaude --proxy-profile work +pcodex --proxy-profile work --search +proxy-client --profile work run -- COMMAND ARG... +``` + +У `pclaude` и `pcodex` параметр `--proxy-profile` должен быть первым. +Обычный `--profile` передаётся запускаемой программе. + +`proxy_on --profile work` меняет окружение терминала, но не профиль по +умолчанию: обычный `codex` наследует это окружение, а `pcodex` без параметра +выберет `default`. `proxy_off` только очищает переменные — работающие прокси, +включая системную службу, остаются запущенными. `myip` использует текущее +окружение. + ### Жизненный цикл Xray ```bash @@ -253,7 +312,7 @@ proxy-client stop ``` `start` сначала проверяет версию и конфигурацию Xray, затем остаётся на переднем -плане. Systemd, фоновый автозапуск и скрытый запуск из `run` не используются. +плане. Скрытого запуска из `run` нет; системная служба устанавливается отдельно. Один loopback-порт принимает HTTP CONNECT и SOCKS5. Остановить Xray можно двумя способами: `Ctrl+C` в окне `start` или @@ -262,7 +321,100 @@ proxy-client stop остаться работать: `status` и `env` тогда сообщат об ошибке, а `stop` добьёт его и уберёт файлы. `stop` работает и после изменения машинного типа на `external`. -Второй `start`, пока первый работает, завершится ошибкой. +Второй `start` того же профиля, пока первый работает, завершится ошибкой. + +## Системная служба + +Этот режим нужен, когда приложения должны получать прокси после загрузки +Linux, до входа пользователя. Требуются systemd, `systemd-sysusers` и права +администратора для установки. Служба одна; именованные профили остаются +пользовательскими. + +Сначала подготовьте и проверьте профиль Xray обычным ручным запуском. Перед +запуском службы остановите ручной экземпляр, если он занимает тот же порт. +Установите службу, указав каталог настроек и уже установленный Xray: + +```bash +sudo "$HOME/.local/bin/proxy-client" service install \ + --config-dir "$HOME/.config/proxy-client" \ + --xray "$HOME/.local/bin/xray" --enable --start +``` + +В качестве `--config-dir` можно указать каталог именованного профиля. +Пути должны быть абсолютными. `--enable` включает запуск при загрузке, +`--start` запускает сейчас. Без этих параметров команда только устанавливает +файлы и регистрирует службу. + +Установщик копирует настройки в `/etc/proxy-client/`, а CLI и Xray — в +`/usr/local/lib/proxy-client/`. Служба больше не зависит от домашнего каталога. +Изменения исходного пользовательского профиля на неё не влияют. Системные +настройки имеют владельца `root`, группу `proxy-client` и права `640`; +каталог — `750`. Процесс работает под отдельным пользователем `proxy-client`. +Временные файлы находятся в закрытом `/run/proxy-client/`. + +Управление и журнал: + +```bash +sudo systemctl status proxy-client +sudo systemctl stop proxy-client +sudo systemctl start proxy-client +sudo systemctl restart proxy-client +sudo journalctl -u proxy-client -b +sudo journalctl -u proxy-client -f +``` + +Для изменения настроек отредактируйте `/etc/proxy-client/config` или +`/etc/proxy-client/default.url` с правами администратора, затем перезапустите +службу. Сохраняйте владельца, группу и права файлов. + +После аварии systemd повторяет запуск с паузой в пять секунд. Явная команда +`stop` оставляет службу остановленной, но не отменяет запуск при следующей +загрузке. Чтобы отключить автозапуск и остановить её сразу: + +```bash +sudo systemctl disable --now proxy-client +``` + +Чтобы включить обратно: + +```bash +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 +``` + +Работающий процесс сам не перезапускается. Чужие или вручную изменённые +исполняемые файлы и unit-файл установщик не заменяет; изменения параметров +systemd делайте через `sudo systemctl edit proxy-client`. Файлы заменяются +по одному атомарно, общего отката всей установки при сбое нет. + +Вывод CLI поступает в journald. Сырой вывод Xray в системном режиме подавлен, +чтобы сообщения с фрагментами конфигурации не раскрывали секреты; детальные +сетевые журналы Xray поэтому недоступны. Установщик сообщает настройку хранения +journald, но не меняет её. Сохранение журнала после перезагрузки зависит от +настроек машины; проверить предыдущие загрузки можно через +`sudo journalctl --list-boots`. + +Открытый локальный порт не гарантирует доступность удалённого сервера. +На ноутбуке сеть может появиться позже службы. Приложениям без повторения +запросов понадобится отдельная настройка ожидания готовности. + +Для `proxy_on`, `pclaude`, `pcodex` и `run` системная служба — внешний прокси: +укажите в нужном пользовательском профиле `type = external`, адрес +`127.0.0.1` и порт службы. Дополнительный профиль типа `xray` запускайте вручную +на другом порту. Пользовательский `stop` системной службой не управляет. + +Для проверки размещения файлов без установки на машину есть +`service install --config-dir DIR --xray FILE --root /absolute/staging`. +Этот режим не вызывает systemd, не создаёт системную учётную запись и не +назначает ей владельцев; он несовместим с `--enable` и `--start`. ## Прокси в настройках Codex и Claude Code diff --git a/tests/test_proxy_client_managed.py b/tests/test_proxy_client_managed.py index be6fe22..35f38ea 100644 --- a/tests/test_proxy_client_managed.py +++ b/tests/test_proxy_client_managed.py @@ -15,7 +15,7 @@ from urllib.parse import quote REPO_ROOT = Path(__file__).resolve().parents[1] -CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" +CLI = Path(os.environ.get("PROXY_CLIENT_TEST_CLI", str(REPO_ROOT / "proxy-client/.local/bin/proxy-client"))) UUID = "11111111-2222-4333-8444-555555555555" PATH_SECRET = "/tenant/PATH_SENTINEL_a1b2" PROFILE = ( @@ -28,7 +28,7 @@ REALITY_PROFILE = ( "vless://{}@edge.example:443?type=tcp&security=reality&encryption=none" "&flow=xtls-rprx-vision&sni=front.example&fp=chrome&pbk={}&sid={}#Synthetic" ).format(UUID, PBK, SID) -READY = "остановка: Ctrl+C или proxy-client stop" +READY = "Управляемый Xray работает на" # Поддельный Xray — симлинк на интерпретатор Python: тогда /proc/PID/exe # совпадает с разрешённым путём к xray, как у настоящего бинарника. @@ -102,6 +102,47 @@ class ManagedXrayTest(unittest.TestCase): self.write_machine_config() self.profile = self.write_profile(self.config_dir / "default.url") + def test_profiles_run_independently_and_export_selected_environment(self): + first = self.start() + named = self.config_dir / "profiles/second" + named.mkdir(parents=True) + second_port = free_port() + (named / "config").write_text( + (self.config_dir / "config").read_text().replace(str(self.port), str(second_port))) + # Переопределение -f должно работать без default.url у второго профиля. + second = self.start("-f", str(self.profile), cli_prefix=["--profile", "second", "start"]) + self.assertIn("proxy-client --profile second stop", self.start_output) + status = self.run_cli("--profile", "second", "status") + self.assertEqual(status.returncode, 0, status.stderr) + env = self.run_cli("--profile", "second", "env") + self.assertIn("127.0.0.1:" + str(second_port), env.stdout) + run = self.run_cli("--profile", "second", "run", "--", sys.executable, + "-c", "import os,sys; print(os.environ['HTTP_PROXY']); sys.exit(23)") + self.assertEqual(run.returncode, 23, run.stderr) + self.assertIn("127.0.0.1:" + str(second_port), run.stdout) + collision = self.run_cli("--profile", "second", "start", "-f", str(self.profile)) + self.assertEqual(collision.returncode, 1) + stopped = self.run_cli("--profile", "second", "stop") + self.assertEqual(stopped.returncode, 0, stopped.stderr) + second.communicate(timeout=10) + self.assertIsNone(first.poll()) + self.assertEqual(self.run_cli("--profile", "default", "status").returncode, 0) + self.assertEqual(self.run_cli("stop").returncode, 0) + first.communicate(timeout=10) + + def test_service_run_uses_explicit_paths_and_pinned_binary(self): + runtime = self.root / "service-runtime" + process = self.start(cli_prefix=[ + "service", "run", "--config-dir", str(self.config_dir), + "--runtime-dir", str(runtime), "--xray", str(self.xray), + ], PATH="/usr/bin:/bin") + self.assertTrue((runtime / "state.json").exists()) + self.assertFalse(self.state_dir.exists()) + process.terminate() + process.communicate(timeout=10) + self.assertEqual(process.returncode, 0) + self.assertFalse((runtime / "client.json").exists()) + 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( @@ -126,10 +167,10 @@ class ManagedXrayTest(unittest.TestCase): check=False, ) - def start(self, *arguments, **environment): + def start(self, *arguments, cli_prefix=None, **environment): """Запускает start и ждёт строку готовности; возвращает процесс.""" process = subprocess.Popen( - [str(CLI), "start"] + list(arguments), + [str(CLI)] + (cli_prefix or ["start"]) + list(arguments), cwd=str(self.root), env=dict(self.environment, **environment), text=True, diff --git a/tests/test_proxy_launchers.sh b/tests/test_proxy_launchers.sh index 249160c..8190d76 100755 --- a/tests/test_proxy_launchers.sh +++ b/tests/test_proxy_launchers.sh @@ -33,8 +33,13 @@ assert_eq() { PROXY_CLIENT_BIN="$TEST_ROOT/proxy-client" cat > "$PROXY_CLIENT_BIN" <<'EOF' #!/usr/bin/env bash +if [ -n "${FAKE_CALL_LOG:-}" ]; then + printf '<%s>\n' "$@" >> "$FAKE_CALL_LOG" +fi if [ "$1" = env ]; then printf '%s\n' "$FAKE_ENV_OUTPUT" +elif [ "$#" -eq 3 ] && [ "$1" = --profile ] && [ "$2" = work ] && [ "$3" = env ]; then + printf '%s\n' "$FAKE_PROFILE_ENV_OUTPUT" else printf '<%s>\n' "$@" fi @@ -69,6 +74,42 @@ source "$SHELL_MODULE" ) ok "proxy_on применяет восемь переменных" +# Выбор профиля меняет окружение только текущей оболочки. +( + unset "${PROXY_KEYS[@]}" + FAKE_ENV_OUTPUT="$VALID_ENV" \ + FAKE_PROFILE_ENV_OUTPUT='HTTP_PROXY=http://work.test:4321 +HTTPS_PROXY=http://work.test:4321 +ALL_PROXY=socks5://work.test:4321 +NO_PROXY=work.test,localhost +http_proxy=http://work.test:4321 +https_proxy=http://work.test:4321 +all_proxy=socks5://work.test:4321 +no_proxy=work.test,localhost' \ + proxy_on --profile work >/dev/null + assert_eq http://work.test:4321 "$HTTP_PROXY" "proxy_on не выбрал профиль work" + assert_eq socks5://work.test:4321 "$all_proxy" "SOCKS выбранного профиля" + assert_eq work.test,localhost "$NO_PROXY" "исключения выбранного профиля" +) +ok "proxy_on выбирает именованный профиль" + +# Неверный синтаксис proxy_on отклоняется до изменения окружения. +assert_invalid_proxy_on() ( + for key in "${PROXY_KEYS[@]}"; do export "$key=old"; done + local rc=0 + FAKE_ENV_OUTPUT="$VALID_ENV" proxy_on "$@" >/dev/null 2>&1 || rc=$? + assert_eq 2 "$rc" "proxy_on принял неверные аргументы: $*" + for key in "${PROXY_KEYS[@]}"; do + assert_eq old "${!key}" "$key изменился после неверных аргументов" + done +) +assert_invalid_proxy_on env +assert_invalid_proxy_on --profile +assert_invalid_proxy_on --profile "" +assert_invalid_proxy_on --profile work extra +assert_invalid_proxy_on --unknown work +ok "proxy_on отклоняет неверные аргументы без изменения окружения" + # Ошибка CLI или непонятный вывод не меняют окружение ни на одну переменную. for case_spec in "1|$VALID_ENV" "0|$VALID_ENV EXTRA_PROXY=bad" "0|BROKEN-LINE"; do @@ -85,15 +126,33 @@ EXTRA_PROXY=bad" "0|BROKEN-LINE"; do done ok "неуспешный env не меняет окружение" +# Даже пригодный вывод не применяется, если проверка выбранного прокси не прошла. +( + for key in "${PROXY_KEYS[@]}"; do export "$key=old"; done + unset no_proxy + if FAKE_RC=1 FAKE_PROFILE_ENV_OUTPUT="$VALID_ENV" proxy_on --profile work >/dev/null 2>&1; then + fail "proxy_on принял отказ проверки выбранного профиля" + fi + for key in "${PROXY_KEYS[@]}"; do + if [[ "$key" == no_proxy ]]; then + [ -z "${!key+set}" ] || fail "$key появился после ошибки" + else + assert_eq old "${!key}" "$key изменился после ошибки выбранного профиля" + fi + done +) +ok "отказ проверки выбранного профиля сохраняет окружение" + # proxy_off очищает оба регистра. ( for key in "${PROXY_KEYS[@]}"; do export "$key=x"; done - proxy_off >/dev/null + FAKE_CALL_LOG="$TEST_ROOT/off-calls" proxy_off >/dev/null + [ ! -e "$TEST_ROOT/off-calls" ] || fail "proxy_off вызвал CLI" for key in "${PROXY_KEYS[@]}"; do [ -z "${!key+set}" ] || fail "$key не очищен" done ) -ok "proxy_off очищает восемь переменных" +ok "proxy_off очищает восемь переменных без вызова CLI" # Обёртки передают CLI команду, аргументы и код возврата без искажений. ( @@ -116,6 +175,60 @@ ok "proxy_off очищает восемь переменных" ) ok "pclaude и pcodex передают аргументы и код выхода" +# Ведущий --proxy-profile относится к прокси, обычный --profile — к программе. +( + for wrapper in pclaude pcodex; do + rc=0 + output="$(FAKE_RC=42 "$wrapper" --proxy-profile work --profile personal "a b" "")" || rc=$? + assert_eq 42 "$rc" "$wrapper не сохранил код возврата с профилем" + assert_eq $'<--profile>\n\n\n<-->\n<'"${wrapper#p}"$'>\n<--profile>\n\n\n<>' "$output" \ + "$wrapper исказил выбор профиля или аргументы ребенка" + done +) +ok "обёртки выбирают профиль прокси и сохраняют аргументы ребенка" + +# Отсутствующее имя профиля не должно запускать дочернюю команду. +( + for wrapper in pclaude pcodex; do + for empty_name in missing empty; do + args=(--proxy-profile) + if [[ "$empty_name" == empty ]]; then args+=(""); fi + rc=0 + output="$("$wrapper" "${args[@]}" 2>/dev/null)" || rc=$? + assert_eq 2 "$rc" "$wrapper принял --proxy-profile без имени" + assert_eq "" "$output" "$wrapper вызвал CLI без имени профиля" + done + done +) +ok "обёртки отклоняют --proxy-profile без имени" + +# Только ведущий --proxy-profile выбирает профиль прокси. +( + for wrapper in pclaude pcodex; do + output="$("$wrapper" --profile personal --proxy-profile child "a b" "")" + assert_eq $'\n<-->\n<'"${wrapper#p}"$'>\n<--profile>\n\n<--proxy-profile>\n\n\n<>' "$output" \ + "$wrapper забрал аргументы ребенка" + done +) +ok "обычный --profile и неведущий --proxy-profile остаются ребенку" + +# Запуск с профилем не меняет окружение вызывающей оболочки. +( + for key in "${PROXY_KEYS[@]}"; do export "$key=old"; done + unset no_proxy + for wrapper in pclaude pcodex; do + "$wrapper" --proxy-profile work >/dev/null + for key in "${PROXY_KEYS[@]}"; do + if [[ "$key" == no_proxy ]]; then + [ -z "${!key+set}" ] || fail "$wrapper экспортировал $key" + else + assert_eq old "${!key}" "$wrapper изменил $key" + fi + done + done +) +ok "обёртки сохраняют окружение вызывающей оболочки" + # .bash_custom подключает и общие функции, и proxy-модуль. ( home="$TEST_ROOT/home" diff --git a/tests/test_proxy_profiles_service.py b/tests/test_proxy_profiles_service.py new file mode 100644 index 0000000..178eceb --- /dev/null +++ b/tests/test_proxy_profiles_service.py @@ -0,0 +1,103 @@ +"""Публичный CLI профилей и установка в изолированный корень.""" +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +CLI = Path(__file__).resolve().parents[1] / 'proxy-client/.local/bin/proxy-client' + + +class ProfilesServiceTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.config = self.root / 'config/proxy-client' + self.config.mkdir(parents=True) + self.env = dict(os.environ, HOME=str(self.root), XDG_CONFIG_HOME=str(self.root / 'config'), + XDG_STATE_HOME=str(self.root / 'state')) + + def cli(self, *args): + return subprocess.run([str(CLI), *map(str, args)], env=self.env, + 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'): + result = self.cli('--profile', 'missing', command) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn('неизвестный профиль', result.stderr) + result = self.cli('--profile', '../escape', 'stop') + self.assertEqual(result.returncode, 1) + self.assertIn('имя профиля', result.stderr) + + def install_args(self): + source = self.root / 'source' + source.mkdir(exist_ok=True) + (source / 'config').write_text('[proxy]\ntype=xray\nname=Service\nno_proxy_extra=\naddress=127.0.0.1\nport=12345\n') + (source / 'default.url').write_text( + 'vless://11111111-2222-4333-8444-555555555555@cdn.example:443' + '?type=xhttp&security=tls&encryption=none&sni=cdn.example&fp=chrome&path=%2Ftest\n') + (source / 'default.url').chmod(0o600) + xray = self.root / 'xray' + xray.write_text('#!/bin/sh\n[ "$1" != version ] || echo "Xray 26.10.10 (fake)"\nexit 0\n') + xray.chmod(0o755) + return ['service', 'install', '--config-dir', source, '--xray', xray, + '--root', self.root / 'stage'] + + def test_staged_install_is_standalone_and_preserves_config(self): + args = self.install_args() + result = self.cli(*args) + self.assertEqual(result.returncode, 0, result.stderr) + stage = self.root / 'stage' + unit = (stage / 'etc/systemd/system/proxy-client.service').read_text() + self.assertIn('User=proxy-client', unit) + self.assertIn('Restart=on-failure', unit) + self.assertIn('RuntimeDirectoryMode=0700', unit) + self.assertIn('StandardOutput=journal', unit) + self.assertNotIn(str(self.root), unit) + self.assertTrue((stage / 'usr/local/lib/proxy-client/xray').is_file()) + self.assertTrue((stage / 'usr/lib/sysusers.d/proxy-client.conf').is_file()) + config = stage / 'etc/proxy-client/config' + config.write_text(config.read_text() + '# preserved\n') + result = self.cli(*args) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('# preserved', config.read_text()) + self.assertEqual((stage / 'etc/proxy-client/default.url').stat().st_mode & 0o777, 0o640) + self.assertIn('journald', result.stdout) + + def test_install_refuses_foreign_files_before_writing(self): + args = self.install_args() + target = self.root / 'stage/etc/systemd/system/proxy-client.service' + target.parent.mkdir(parents=True) + target.write_text('foreign unit') + result = self.cli(*args) + self.assertEqual(result.returncode, 1) + self.assertEqual(target.read_text(), 'foreign unit') + self.assertFalse((self.root / 'stage/usr/local/lib/proxy-client/xray').exists()) + + def test_install_reports_journal_storage_without_changing_policy(self): + args = self.install_args() + policy = self.root / 'stage/etc/systemd/journald.conf.d/storage.conf' + policy.parent.mkdir(parents=True) + policy.write_text('[Journal]\nStorage=volatile\n') + result = self.cli(*args) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('Storage=volatile', result.stdout) + self.assertIn('не сохраняется', result.stdout) + self.assertEqual(policy.read_text(), '[Journal]\nStorage=volatile\n') + + def test_update_does_not_require_original_home_profile(self): + args = self.install_args() + first = self.cli(*args) + self.assertEqual(0, first.returncode, first.stderr) + for path in (self.root / 'source').iterdir(): + path.unlink() + (self.root / 'source').rmdir() + updated = self.cli(*args) + self.assertEqual(0, updated.returncode, updated.stderr) + + def test_unspecified_journal_storage_is_not_guessed(self): + result = self.cli(*self.install_args()) + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn('Storage не задан', result.stdout)