From fb23b031bc885bba5a0ab2b080bc6a134b274d37 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 09:21:39 -0400 Subject: [PATCH 1/9] =?UTF-8?q?docs(scratch):=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=20=D1=83=D0=BF=D1=80=D0=BE=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20pro?= =?UTF-8?q?xy-client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../proxy-client-simplify/issues/01-cli.md | 64 ++++++++++++++ .../issues/02-installer-shell.md | 58 ++++++++++++ .scratch/proxy-client-simplify/spec.md | 88 +++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 .scratch/proxy-client-simplify/issues/01-cli.md create mode 100644 .scratch/proxy-client-simplify/issues/02-installer-shell.md create mode 100644 .scratch/proxy-client-simplify/spec.md diff --git a/.scratch/proxy-client-simplify/issues/01-cli.md b/.scratch/proxy-client-simplify/issues/01-cli.md new file mode 100644 index 0000000..6633199 --- /dev/null +++ b/.scratch/proxy-client-simplify/issues/01-cli.md @@ -0,0 +1,64 @@ +# 01. Упростить CLI proxy-client и его Python-тесты + +Status: ready-for-agent + +Спецификация: `../spec.md` (читать целиком — там список того, что сохраняем). + +## Файлы + +- `proxy-client/.local/bin/proxy-client` +- `tests/test_proxy_client.py`, `tests/test_proxy_client_managed.py` + (можно слить, переименовать или разнести по-новому — как станет проще) +- `proxy-client/README.md` — все разделы, **кроме** «Установка» и «Ручное + подключение без установщика» (их правит задача 02; при конфликте правки + перечитай файл и повтори). +- `proxy-client/.config/proxy-client/examples/` — если нужно. + +Не трогать: `proxy-client/install.sh`, `shell.bash`, shell-тесты. + +## Что сделать + +Пункты 1–4 и 6 спецификации для CLI. Затем привести тесты к пяти местам +проверок из спецификации: + +- Все проверки разбора ссылки (ALPN, legacy insecure, некорректные ссылки, + соответствие полей REALITY/XHTTP, pcs/vcn, extra, шифрование) — табличные + тесты `parse_vless`/`build_xray_config`, без запуска процессов. +- CLI-тесты управляемого Xray: один полный жизненный цикл (start → status/env/ + run → stop → файлы удалены, права 600/700, секретов нет в выводе), Ctrl+C, + SIGKILL супервизора → `stop` добивает Xray, устаревшие файлы после смерти + процессов, занятый порт, не-loopback адрес, права профиля (с новой + подсказкой), сбой `xray run -test` не оставляет файлов, сбой определения + внешнего IP — лишь предупреждение, второй `start` при работающем — отказ. + Каждый сценарий — один запуск. Готовность ждать по строке вывода, а не + опросом `status` в цикле, если это упрощает. +- Удалить тесты-пустышки: проверку тестовой подделки сокета, проверку, что + поддельный xray отвергает не-JSON, тесты порядка внутренних вызовов, всё, + что подменяет функции модуля через `__globals__`. Подделку сокета + (`checking_socket_setup`/`run_cli_with_socket_setup` через runpy) заменить + настоящим слушающим сокетом на 127.0.0.1 (и закрытым портом для «недоступен»). +- Тесты удалённых возможностей (ограничения размера, строгий `.dgst`, + запрет заглавных букв в хеше, «каталог установки внутри HOME», boot_id, + повторный PID, подмена xray_path, способы вызова супервизора и т. п.) уходят + вместе с возможностями. +- Новая подсказка о правах — тест первым (красный → зелёный). + +Упрощай и сам код: если функция после правок становится проходной — встрой. +Сообщения об ошибках — на русском, коротко, с путём к файлу там, где речь о +файле. Сохраняй совместимость с Python 3.8 (без `match`, `str.removeprefix`, +`|` в типах и т. п.). + +## Готово, когда + +- `python3 -m unittest discover -s tests` зелёный; прогон Python-тестов + proxy-client заметно быстрее нынешних ~75 с. +- Если есть `uv`: `uv run --python 3.8 python -m unittest discover -s tests` + тоже зелёный. +- Реальная проверка: сгенерированный конфиг для синтетических ссылок REALITY + и XHTTP с шифрованием проходит `xray run -test` (xray 26.3.27 есть в + `~/.local/bin/xray`). +- README описывает текущее поведение, без упоминаний удалённого. +- Не коммитить: коммиты делает оркестратор. + +Отчёт: что удалено из кода и тестов (строки до/после, время прогона до/после), +какие решения принял сам, что осталось спорным. diff --git a/.scratch/proxy-client-simplify/issues/02-installer-shell.md b/.scratch/proxy-client-simplify/issues/02-installer-shell.md new file mode 100644 index 0000000..b567e14 --- /dev/null +++ b/.scratch/proxy-client-simplify/issues/02-installer-shell.md @@ -0,0 +1,58 @@ +# 02. Упростить установщик пакета, shell-тесты и запускалку + +Status: ready-for-agent + +Спецификация: `../spec.md` (читать целиком). + +## Файлы + +- `proxy-client/install.sh` +- `proxy-client/.config/proxy-client/shell.bash` — только если упрощение + очевидно; поведение `proxy_on`/`proxy_off`/`pclaude`/`pcodex` не меняется. +- `tests/test_proxy_client_install.sh`, `tests/test_proxy_launchers.sh`, + `tests/test_proxy_client_full.sh` +- `proxy-client/README.md` — только разделы «Установка» и «Ручное подключение + без установщика» (остальное правит задача 01; при конфликте правки + перечитай файл и повтори). + +Не трогать: `proxy-client/.local/bin/proxy-client`, Python-тесты, корневой +`install.sh` и `tests/test_install.sh` (кроме случая, если они ломаются от +твоих правок — тогда минимально поправь и сообщи). + +## Что сделать + +1. Пункт 5 спецификации: `install.sh` ставит три симлинка напрямую (`ln -s`), + без Stow и `STOW_IGNORE_PATTERNS`. Повторный запуск — ничего не меняет. + Чужие файлы не перезаписывает и перечисляет все конфликты сразу (битая + ссылка — тоже конфликт). `~/.local` и `~/.config`, которые сами ссылки, + допустимы. Блок в `~/.bashrc` — дописать, если нет маркера; + `--no-shell-setup` сохраняется. Машинные `config` и `default.url` никогда + не ссылаются из пакета. +2. Тесты установщика: свежая установка (три ссылки, права, `bash -ic` после + установки находит `proxy-client` и `proxy_on`), повторный запуск ничего не + меняет, конфликты (включая битую ссылку) без частичной установки, машинные + файлы сохраняются, `--no-shell-setup`, родительские каталоги-ссылки + работают. Тесты удалённого поведения — удалить. +3. Тесты функций Bash: вместо переопределения внутренней `_proxy_cli` — + поддельный исполняемый файл через `PROXY_CLIENT_BIN` (посмотри, как это + сделано в `tests/test_chatgpt_proxy_launcher.sh`). Удалить тест голого + `declare -F`, тест «владение перенесено» (охрана давнего рефакторинга) и + ловушки `curl`. Сохранить: `proxy_on` применяет переменные, неуспешный + `env` не меняет окружение, `proxy_off` чистит восемь переменных, + `pclaude`/`pcodex` передают аргументы и код выхода, `.bash_custom` + подключает модули. +4. `tests/test_proxy_client_full.sh` запускает только тесты proxy-client + (Python-тесты proxy-client, env_sync, shell-тесты proxy-client). Прогон + под Python 3.8 через `uv` — если `uv` есть, иначе предупреждение и пропуск. + В README (раздел «Установка» или новый короткий раздел в конце — + согласуй с задачей 01 через простоту: одна строка команды) — как запускать. + +## Готово, когда + +- Все изменённые shell-тесты зелёные; `tests/test_install.sh` тоже зелёный. +- Тесты не требуют установленного `stow`. +- `bash -n` и (если есть) `shellcheck` для изменённых скриптов чистые. +- Не коммитить: коммиты делает оркестратор. + +Отчёт: строки до/после, время до/после, принятые самостоятельно решения, +что осталось спорным. diff --git a/.scratch/proxy-client-simplify/spec.md b/.scratch/proxy-client-simplify/spec.md new file mode 100644 index 0000000..cbcff93 --- /dev/null +++ b/.scratch/proxy-client-simplify/spec.md @@ -0,0 +1,88 @@ +# Упрощение proxy-client + +Status: ready-for-agent + +## Зачем + +`proxy-client` — личный клиент для владельца и нескольких друзей, а не +промышленное ПО. Большая часть кода и тестов — защита от сценариев, которые на +личной машине не случаются (повторное использование PID, подложный state.json, +zip-бомба в проверенном по SHA-256 официальном архиве, гонка `status` с +параллельным `start`). Эта защита стоит сотни строк кода и тестов и около +минуты прогона. Цель — оставить существенную сложность и убрать случайную. + +Принцип: **простой код, который честно делает своё дело и понятно сообщает об +ошибке**. Не защищаемся от самого пользователя и от его собственных файлов. + +## Что сохраняем (обязательное поведение) + +- Команды и их договор: `install [--replace]`, `status`, `env`, `run -- CMD`, + `start [-f FILE]`, `stop`; коды выхода (2 без подкоманды, 127 — команда не + найдена, 128+N — дочерний процесс убит сигналом N); восемь proxy-переменных. +- Отказ без запасного пути: выбран Xray, но он не запущен — ошибка, а не + переход на Hiddify. +- Машинный INI: строгий формат как сейчас (он маленький и понятный). +- Разбор VLESS: поддерживаемые TCP/REALITY и XHTTP/TLS, VLESS-шифрование + `mlkem768x25519plus`, неизвестные query-параметры отклоняются. +- Точная версия Xray 26.3.27 для `install` и `start` (сознательное решение). +- Установка Xray: официальный архив, проверка SHA-256 по `.dgst`, атомарная + замена через временный каталог рядом с назначением; `--replace` не пишет + через симлинк; неудачная установка не портит старый бинарник. +- `start` только на loopback-адресе; занятый порт — понятная ошибка. +- Не выводим UUID, ключи REALITY и `path` XHTTP в stdout/stderr. +- `client.json` и `state.json` — права 600 в каталоге 700 (в них секреты). +- Один управляемый Xray за раз: блокировка `flock` на время `start`. +- Остановка: Ctrl+C или `stop` гасят Xray и удаляют служебные файлы; если + супервизор убит (SIGKILL), `stop` добивает оставшийся Xray и чистит файлы. +- Python 3.8+ (у друзей старые дистрибутивы), только стандартная библиотека. + +## Что упрощаем + +1. **Проверка «тот ли процесс».** Вместо boot_id + starttime + `/proc/PID/exe` + + разрешения argv супервизора относительно его cwd: Xray — PID жив и + `/proc/PID/exe` совпадает с `xray_path`; супервизор — PID жив (и, если + дёшево, его cmdline упоминает `proxy-client`). Непохожий процесс считаем + мёртвым: сигнал не шлём, файлы считаем устаревшими и чистим. Состояние + «не удалось подтвердить, удалите файлы вручную» исчезает. Двойная проверка + под блокировкой при чистке устаревших файлов не нужна. state.json читаем + без строгой схемы: битый файл — понятная ошибка с путём. +2. **Скачивание Xray.** Убрать три уровня ограничений размера, сверку + Content-Length, строгую грамматику `.dgst`. Достаточно: скачать, найти в + `.dgst` строку `SHA2-256=`, сравнить без учёта регистра. Убрать + `O_EXCL`/0600 для кандидата внутри закрытого временного каталога и + требование «каталог установки внутри $HOME». +3. **`install` и работающий Xray.** Замена бинарника под работающим процессом + на Linux безопасна: не блокировать `install`, а напомнить перезапустить + `start`. Блокировку start на время install не брать. +4. **Разбор ссылки.** `extra` — обычный `json.loads` + проверка, что это + объект (без своих проверок повторных ключей и NaN: `xray run -test` + проверит). `allowInsecure`/`insecure` — принимаются с любым значением и + игнорируются; при истинном значении (`1`, `true`) — предупреждение. +5. **Установщик пакета `proxy-client/install.sh`.** Один способ — прямые + симлинки (без Stow). Не перезаписывать чужие файлы и сообщить обо всех + конфликтах сразу. Разрешить `~/.local` и `~/.config`, которые сами являются + ссылками. Блок в `~/.bashrc` — дописать, если нет маркера; без разбора + «повреждённых» блоков, поиска `.bash_custom` и точного текста `export PATH`. +6. **Подсказки об ошибках с файлами** называют файл полным путём и дают + готовую команду. Пример: «файл профиля /home/u/.config/proxy-client/default.url + доступен другим пользователям; выполните: chmod 600 /home/u/...». Если + профиль — симлинк, называть конечный файл (права ставятся на него). + +## Где живут тесты (согласовано с владельцем) + +1. Команда `proxy-client`: argv → код выхода, вывод, созданные файлы, + запущенные и остановленные процессы. +2. Разбор ссылки: `parse_vless` + `build_xray_config` (ссылка → JSON Xray) — + быстрые табличные тесты без запуска процессов. +3. Установка Xray: `command_install` с подставляемым скачивателем. +4. Функции Bash: через поддельный исполняемый файл в `PROXY_CLIENT_BIN`. +5. Установщик пакета: через временный HOME. + +Больше нигде. Запрещено: подмена внутренних функций через `__globals__` / +`mock.patch.dict`, проверки порядка внутренних вызовов, тесты тестовых +подделок, по нескольку полных циклов start/stop ради одного утверждения. + +## Задачи + +- `issues/01-cli.md` — CLI и Python-тесты. +- `issues/02-installer-shell.md` — установщик пакета, shell-тесты, запускалка. From 05a7f1e4fbe133a0b99f1be231a41bee0b20bcf6 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 09:22:28 -0400 Subject: [PATCH 2/9] =?UTF-8?q?docs(adr):=20proxy-client=20=E2=80=94=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20=D0=B8=D0=BD=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82,=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=82=D0=B0=20=D0=B2=D0=B0=D0=B6=D0=BD=D0=B5?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0001-proxy-client-lichnyj-instrument.md | 38 +++++++++++++++++++ docs/specs/2026-07-15-proxy-client-design.md | 4 ++ 2 files changed, 42 insertions(+) create mode 100644 docs/adr/0001-proxy-client-lichnyj-instrument.md diff --git a/docs/adr/0001-proxy-client-lichnyj-instrument.md b/docs/adr/0001-proxy-client-lichnyj-instrument.md new file mode 100644 index 0000000..fd8f9d8 --- /dev/null +++ b/docs/adr/0001-proxy-client-lichnyj-instrument.md @@ -0,0 +1,38 @@ +# 0001. proxy-client — личный инструмент: простота важнее защиты + +Дата: 2026-09-28. Статус: принято. + +## Контекст + +`proxy-client` пользуются владелец и несколько друзей на своих машинах. Первая +версия защищалась от сценариев, которые на личной машине не случаются: +повторное использование PID между `start` и `stop`, подложный `state.json`, +zip-бомба в официальном архиве Xray, уже проверенном по SHA-256, гонка +`status` с параллельным `start`. Эта защита стоила сотен строк кода и тестов и +около минуты прогона. При этом она мешала в обычной жизни, например +отказывала, если `~/.local` сделан ссылкой. + +## Решение + +Защищаемся только от реальных ошибок и опасностей: + +- проверка SHA-256 скачанного Xray и атомарная замена бинарника; +- секреты профиля не попадают в вывод, служебные файлы закрыты правами 600/700; +- локальный порт Xray слушает только loopback; +- чужие файлы не перезаписываются; +- сигнал уходит только процессу, у которого `/proc/PID/exe` совпадает с + запущенным Xray. + +Не защищаемся от самого пользователя и его собственных файлов. Ошибка должна +быть понятной: называть файл полным путём и подсказывать готовую команду. + +Тесты проверяют поведение только через команду `proxy-client`, разбор ссылки, +установку Xray с подставным скачивателем, функции Bash и установщик пакета. Их +не пишут на внутренние функции и порядок вызовов. + +## Последствия + +Прежде чем добавлять новую защиту, нужно назвать сценарий, который реально +случается у пользователя. Иначе защита не добавляется. Первоначальный замысел +с полным набором защит описан в `docs/specs/2026-07-15-proxy-client-design.md` +и в части защит больше не действует. diff --git a/docs/specs/2026-07-15-proxy-client-design.md b/docs/specs/2026-07-15-proxy-client-design.md index e4b3a4a..8aa8152 100644 --- a/docs/specs/2026-07-15-proxy-client-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -5,6 +5,10 @@ Статус: основная реализация принята вручную; самостоятельный установщик спроектирован 2026-07-21, но ещё не реализован +> 2026-09-28: часть защит отсюда снята как избыточная для личного инструмента, +> см. [ADR 0001](../adr/0001-proxy-client-lichnyj-instrument.md). При +> расхождении действует ADR. + ## Проблема На удалённых Linux-серверах нет GUI-клиента Hiddify. Перед запуском Claude Code From 502f828c6f5105ccc500576999ba70175981c089 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 09:23:13 -0400 Subject: [PATCH 3/9] =?UTF-8?q?docs(proxy-client):=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=86=D0=B8=D0=BF=20=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=82=D1=8B=20=E2=80=94=20=D0=B2=20=D1=81=D0=BF=D0=B5=D1=86?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20ADR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отдельный ADR дублировал спецификацию и оставлял в ней устаревшие места. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0001-proxy-client-lichnyj-instrument.md | 38 ------------------- docs/specs/2026-07-15-proxy-client-design.md | 31 ++++++++++++--- 2 files changed, 26 insertions(+), 43 deletions(-) delete mode 100644 docs/adr/0001-proxy-client-lichnyj-instrument.md diff --git a/docs/adr/0001-proxy-client-lichnyj-instrument.md b/docs/adr/0001-proxy-client-lichnyj-instrument.md deleted file mode 100644 index fd8f9d8..0000000 --- a/docs/adr/0001-proxy-client-lichnyj-instrument.md +++ /dev/null @@ -1,38 +0,0 @@ -# 0001. proxy-client — личный инструмент: простота важнее защиты - -Дата: 2026-09-28. Статус: принято. - -## Контекст - -`proxy-client` пользуются владелец и несколько друзей на своих машинах. Первая -версия защищалась от сценариев, которые на личной машине не случаются: -повторное использование PID между `start` и `stop`, подложный `state.json`, -zip-бомба в официальном архиве Xray, уже проверенном по SHA-256, гонка -`status` с параллельным `start`. Эта защита стоила сотен строк кода и тестов и -около минуты прогона. При этом она мешала в обычной жизни, например -отказывала, если `~/.local` сделан ссылкой. - -## Решение - -Защищаемся только от реальных ошибок и опасностей: - -- проверка SHA-256 скачанного Xray и атомарная замена бинарника; -- секреты профиля не попадают в вывод, служебные файлы закрыты правами 600/700; -- локальный порт Xray слушает только loopback; -- чужие файлы не перезаписываются; -- сигнал уходит только процессу, у которого `/proc/PID/exe` совпадает с - запущенным Xray. - -Не защищаемся от самого пользователя и его собственных файлов. Ошибка должна -быть понятной: называть файл полным путём и подсказывать готовую команду. - -Тесты проверяют поведение только через команду `proxy-client`, разбор ссылки, -установку Xray с подставным скачивателем, функции Bash и установщик пакета. Их -не пишут на внутренние функции и порядок вызовов. - -## Последствия - -Прежде чем добавлять новую защиту, нужно назвать сценарий, который реально -случается у пользователя. Иначе защита не добавляется. Первоначальный замысел -с полным набором защит описан в `docs/specs/2026-07-15-proxy-client-design.md` -и в части защит больше не действует. diff --git a/docs/specs/2026-07-15-proxy-client-design.md b/docs/specs/2026-07-15-proxy-client-design.md index 8aa8152..6a06f3c 100644 --- a/docs/specs/2026-07-15-proxy-client-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -2,12 +2,33 @@ Дата: 2026-07-15 -Статус: основная реализация принята вручную; самостоятельный установщик -спроектирован 2026-07-21, но ещё не реализован +Статус: реализовано; 2026-09-28 упрощено по принципу ниже -> 2026-09-28: часть защит отсюда снята как избыточная для личного инструмента, -> см. [ADR 0001](../adr/0001-proxy-client-lichnyj-instrument.md). При -> расхождении действует ADR. +## Принцип: личный инструмент + +`proxy-client` пользуются владелец и несколько друзей на своих машинах. Код +защищается от реальных ошибок и опасностей: + +- скачанный Xray проверяется по SHA-256 и заменяется атомарно; +- секреты профиля не попадают в вывод, служебные файлы закрыты правами 600/700; +- локальный порт Xray слушает только loopback; +- чужие файлы не перезаписываются; +- сигнал уходит только процессу, у которого `/proc/PID/exe` совпадает с + запущенным Xray. + +Пользователь и его собственные файлы — не противник. Сообщение об ошибке +называет файл полным путём и подсказывает готовую команду. + +Новая защита появляется вместе со сценарием, который реально случается у +пользователя, и этот сценарий записывается рядом с ней. Так же проверяется и +прежний код: в 2026-09-28 убраны защиты без такого сценария — повторное +использование PID, подложный `state.json`, zip-бомба в проверенном архиве, +гонка `status` с параллельным `start`. + +Тесты проверяют поведение через команду `proxy-client`, разбор ссылки +(`parse_vless` + `build_xray_config`), установку Xray с подставным +скачивателем, функции Bash с поддельным `PROXY_CLIENT_BIN` и установщик пакета +во временном HOME. ## Проблема From 06ca4ab0c665dbb70024668c327045803a47e7b4 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 09:28:07 -0400 Subject: [PATCH 4/9] =?UTF-8?q?refactor(proxy-client):=20=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=82=D0=B8=D1=82=D1=8C=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D1=89=D0=B8=D0=BA=20=D0=BF=D0=B0=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=20=D0=B8=20shell-=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh ставит три прямые ссылки без Stow; чужие файлы не трогает и перечисляет все конфликты сразу; ~/.local и ~/.config могут быть ссылками; блок в ~/.bashrc дописывается, если нет маркера. - proxy_on читает вывод proxy-client env без временного файла. - Тесты Bash-функций работают через поддельный PROXY_CLIENT_BIN. - test_proxy_client_full.sh запускает только тесты proxy-client, прогон под Python 3.8 — если есть uv. Co-Authored-By: Claude Opus 5.5 (1M context) --- proxy-client/.config/proxy-client/shell.bash | 42 +- proxy-client/install.sh | 189 ++----- tests/test_proxy_client_full.sh | 22 +- tests/test_proxy_client_install.sh | 510 ++++++------------- tests/test_proxy_launchers.sh | 216 ++++---- 5 files changed, 281 insertions(+), 698 deletions(-) diff --git a/proxy-client/.config/proxy-client/shell.bash b/proxy-client/.config/proxy-client/shell.bash index 63a6fcd..a8920e7 100644 --- a/proxy-client/.config/proxy-client/shell.bash +++ b/proxy-client/.config/proxy-client/shell.bash @@ -5,21 +5,17 @@ _proxy_cli() { "${PROXY_CLIENT_BIN:-proxy-client}" "$@" } -_proxy_apply_env_file() { - local output_file="$1" line key value +# Применяет вывод "proxy-client env" целиком или не применяет ничего. +_proxy_apply_env() { + local line key local -A proxy_values=() - local -a allowed_keys=( - HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY - http_proxy https_proxy all_proxy no_proxy - ) - while IFS= read -r line || [ -n "$line" ]; do + while IFS= read -r line; do if [[ "$line" != *=* ]]; then echo "Ошибка: proxy-client env вернул строку без '='" >&2 return 1 fi key="${line%%=*}" - value="${line#*=}" case "$key" in HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|http_proxy|https_proxy|all_proxy|no_proxy) ;; *) @@ -27,38 +23,20 @@ _proxy_apply_env_file() { return 1 ;; esac - proxy_values["$key"]="$value" - done < "$output_file" + proxy_values["$key"]="${line#*=}" + done <<< "$1" - for key in "${allowed_keys[@]}"; do - if [[ -n "${proxy_values[$key]+set}" ]]; then - printf -v "$key" '%s' "${proxy_values[$key]}" - export "$key" - fi + for key in "${!proxy_values[@]}"; do + export "$key=${proxy_values[$key]}" done } proxy_on() { - local output_file old_umask - old_umask="$(umask)" - umask 077 - if ! output_file="$(mktemp "${TMPDIR:-/tmp}/proxy-client-env.XXXXXX")"; then - umask "$old_umask" - echo "Ошибка: не удалось создать временный файл для proxy-env" >&2 - return 1 - fi - umask "$old_umask" - if ! _proxy_cli env > "$output_file"; then - rm -f "$output_file" + local output + if ! output="$(_proxy_cli env)" || ! _proxy_apply_env "$output"; then echo "Ошибка: прокси не включён" >&2 return 1 fi - if ! _proxy_apply_env_file "$output_file"; then - rm -f "$output_file" - echo "Ошибка: прокси не включён" >&2 - return 1 - fi - rm -f "$output_file" echo "Прокси включён" } diff --git a/proxy-client/install.sh b/proxy-client/install.sh index 133858b..2b0a454 100755 --- a/proxy-client/install.sh +++ b/proxy-client/install.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash +# Ставит proxy-client в домашний каталог: три ссылки на этот каталог и +# (по умолчанию) блок в ~/.bashrc. Повторный запуск ничего не меняет. set -euo pipefail PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -STOW_BIN="${STOW_BIN:-stow}" SETUP_SHELL=1 usage() { @@ -15,9 +16,7 @@ EOF while [ "$#" -gt 0 ]; do case "$1" in - --no-shell-setup) - SETUP_SHELL=0 - ;; + --no-shell-setup) SETUP_SHELL=0 ;; -h|--help) usage exit 0 @@ -31,180 +30,58 @@ while [ "$#" -gt 0 ]; do shift done -SOURCES=( - "$PACKAGE_DIR/.local/bin/proxy-client" - "$PACKAGE_DIR/.config/proxy-client/shell.bash" - "$PACKAGE_DIR/.config/proxy-client/examples" -) -TARGETS=( - "$HOME/.local/bin/proxy-client" - "$HOME/.config/proxy-client/shell.bash" - "$HOME/.config/proxy-client/examples" -) -STOW_IGNORE_PATTERNS=( - '^\.local/bin/proxy-client$' - '^\.config/proxy-client/shell\.bash$' - '^\.config/proxy-client/examples$' +# Машинные config и default.url сюда не входят: они живут только в $HOME. +LINKS=( + .local/bin/proxy-client + .config/proxy-client/shell.bash + .config/proxy-client/examples ) -source_errors=() -[ -x "${SOURCES[0]}" ] || source_errors+=("${SOURCES[0]}") -[ -f "${SOURCES[1]}" ] || source_errors+=("${SOURCES[1]}") -[ -d "${SOURCES[2]}" ] || source_errors+=("${SOURCES[2]}") -if [ "${#source_errors[@]}" -gt 0 ]; then - echo "Ошибка: отсутствуют обязательные файлы исходного пакета:" >&2 - printf ' %s\n' "${source_errors[@]}" >&2 - exit 1 -fi - -"${SOURCES[0]}" --help >/dev/null -bash -n "${SOURCES[1]}" - -SHELL_BLOCK_START='# >>> proxy-client >>>' -SHELL_BLOCK_END='# <<< proxy-client <<<' -SHELL_SETUP_NEEDED=0 -BASH_CUSTOM_HAS_MODULE=0 -SHELL_PATH_NEEDED=1 -SHELL_SOURCE_NEEDED=1 -SHELL_BLOCK_CONFLICT=0 -if [ "$SETUP_SHELL" -eq 1 ]; then - if [ -f "$HOME/.bash_custom" ] \ - && grep -Fq 'proxy-client/shell.bash' "$HOME/.bash_custom"; then - BASH_CUSTOM_HAS_MODULE=1 - SHELL_SOURCE_NEEDED=0 - fi - - bashrc_start_count=0 - bashrc_end_count=0 - if [ -f "$HOME/.bashrc" ]; then - bashrc_start_count="$( - grep -Fxc "$SHELL_BLOCK_START" "$HOME/.bashrc" || true - )" - bashrc_end_count="$( - grep -Fxc "$SHELL_BLOCK_END" "$HOME/.bashrc" || true - )" - if grep -Fq 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc"; then - SHELL_PATH_NEEDED=0 - fi - fi - - if [ "$bashrc_start_count" -eq 0 ] && [ "$bashrc_end_count" -eq 0 ]; then - if [ "$SHELL_PATH_NEEDED" -eq 1 ] \ - || [ "$SHELL_SOURCE_NEEDED" -eq 1 ]; then - SHELL_SETUP_NEEDED=1 - fi - elif [ "$bashrc_start_count" -eq 1 ] \ - && [ "$bashrc_end_count" -eq 1 ] \ - && [ "$SHELL_PATH_NEEDED" -eq 0 ] \ - && { [ "$BASH_CUSTOM_HAS_MODULE" -eq 1 ] \ - || grep -Fq 'proxy-client/shell.bash' "$HOME/.bashrc"; }; then - : - else - SHELL_BLOCK_CONFLICT=1 - fi -fi - +# Существующая ссылка на этот же файл пакета — не конфликт, всё остальное +# (включая битую ссылку) не трогаем. conflicts=() -missing_targets=0 -stow_ignore_args=() -PARENT_DIRS=( - "$HOME/.local" - "$HOME/.local/bin" - "$HOME/.config" - "$HOME/.config/proxy-client" -) -for parent_dir in "${PARENT_DIRS[@]}"; do - if { [ -e "$parent_dir" ] || [ -L "$parent_dir" ]; } \ - && { [ ! -d "$parent_dir" ] || [ -L "$parent_dir" ]; }; then - conflicts+=("$parent_dir") - fi -done - -for index in "${!TARGETS[@]}"; do - target="${TARGETS[$index]}" - source_path="${SOURCES[$index]}" +for link in "${LINKS[@]}"; do + target="$HOME/$link" if [ -e "$target" ] || [ -L "$target" ]; then - if [ ! -L "$target" ] \ - || [ "$(readlink -f "$target" 2>/dev/null || true)" != "$source_path" ]; then + if [ "$(readlink -f "$target" 2>/dev/null)" != "$PACKAGE_DIR/$link" ]; then conflicts+=("$target") - else - stow_ignore_args+=("--ignore=${STOW_IGNORE_PATTERNS[$index]}") fi - else - missing_targets=$((missing_targets + 1)) fi done -if [ "$SHELL_SETUP_NEEDED" -eq 1 ] \ - && { [ -e "$HOME/.bashrc" ] || [ -L "$HOME/.bashrc" ]; } \ - && [ ! -f "$HOME/.bashrc" ]; then - conflicts+=("$HOME/.bashrc") -fi -if [ "$SHELL_BLOCK_CONFLICT" -eq 1 ]; then - conflicts+=("$HOME/.bashrc") -fi - if [ "${#conflicts[@]}" -gt 0 ]; then - echo "Ошибка: найдены конфликтующие пути:" >&2 + echo "Ошибка: пути уже заняты, установка не выполнена:" >&2 printf ' %s\n' "${conflicts[@]}" >&2 - echo "Разберите их вручную и повторите установку." >&2 + echo "Удалите или переименуйте их и повторите установку." >&2 exit 1 fi mkdir -p "$HOME/.local/bin" "$HOME/.config/proxy-client" +for link in "${LINKS[@]}"; do + [ -e "$HOME/$link" ] || ln -s "$PACKAGE_DIR/$link" "$HOME/$link" +done -if command -v "$STOW_BIN" >/dev/null 2>&1; then - echo "Установка с помощью Stow." - if [ "$missing_targets" -gt 0 ]; then - "$STOW_BIN" --dir="$(dirname "$PACKAGE_DIR")" --target="$HOME" \ - "${stow_ignore_args[@]}" \ - "$(basename "$PACKAGE_DIR")" - fi +SHELL_BLOCK_START='# >>> proxy-client >>>' +if [ "$SETUP_SHELL" -eq 0 ]; then + echo "Настройка Bash пропущена (--no-shell-setup)." + # Команда для пользователя: $HOME и $PATH раскроются в его сессии. + # shellcheck disable=SC2016 + RELOAD_COMMAND='export PATH="$HOME/.local/bin:$PATH"; source ~/.config/proxy-client/shell.bash' else - echo "Установка без Stow: создаю символические ссылки напрямую." - for index in "${!TARGETS[@]}"; do - target="${TARGETS[$index]}" - source_path="${SOURCES[$index]}" - if [ ! -L "$target" ]; then - ln -s "$source_path" "$target" - fi - done -fi + if ! grep -Fqx "$SHELL_BLOCK_START" "$HOME/.bashrc" 2>/dev/null; then + cat >> "$HOME/.bashrc" </dev/null -bash -n "$HOME/.config/proxy-client/shell.bash" - -if [ "$SHELL_SETUP_NEEDED" -eq 1 ]; then - cat >> "$HOME/.bashrc" <<'EOF' - -# >>> proxy-client >>> -EOF - if [ "$SHELL_PATH_NEEDED" -eq 1 ]; then - cat >> "$HOME/.bashrc" <<'EOF' -case ":$PATH:" in - *":$HOME/.local/bin:"*) ;; - *) - export PATH="$HOME/.local/bin:$PATH" - ;; +$SHELL_BLOCK_START +case ":\$PATH:" in + *":\$HOME/.local/bin:"*) ;; + *) export PATH="\$HOME/.local/bin:\$PATH" ;; esac -EOF - fi - if [ "$SHELL_SOURCE_NEEDED" -eq 1 ]; then - cat >> "$HOME/.bashrc" <<'EOF' -[ -f "$HOME/.config/proxy-client/shell.bash" ] && source "$HOME/.config/proxy-client/shell.bash" -EOF - fi - cat >> "$HOME/.bashrc" <<'EOF' +[ -f "\$HOME/.config/proxy-client/shell.bash" ] && source "\$HOME/.config/proxy-client/shell.bash" # <<< proxy-client <<< EOF -elif [ "$SETUP_SHELL" -eq 0 ]; then - echo "Настройка Bash пропущена (--no-shell-setup)." -fi - -if [ "$SETUP_SHELL" -eq 1 ]; then + echo "В ~/.bashrc добавлен блок proxy-client." + fi RELOAD_COMMAND='source ~/.bashrc' -else - RELOAD_COMMAND='export PATH="$HOME/.local/bin:$PATH"; source ~/.config/proxy-client/shell.bash' fi cat </dev/null 2>&1; then - echo "Ошибка: для полной проверки требуется uv" >&2 - exit 1 -fi - +cd "$(dirname "${BASH_SOURCE[0]}")/.." export PYTHONDONTWRITEBYTECODE=1 -python3 -m unittest discover -s tests +python3 -m unittest discover -s tests -p 'test_proxy_*.py' +if command -v uv >/dev/null 2>&1; then + uv run --python 3.8 --no-project \ + python -m unittest discover -s tests -p 'test_proxy_*.py' +else + echo "Предупреждение: uv не найден, прогон под Python 3.8 пропущен" >&2 +fi bash tests/test_proxy_launchers.sh -bash tests/test_chatgpt_proxy_launcher.sh bash tests/test_proxy_client_install.sh -bash tests/test_install.sh -uv run --python 3.8 --no-project python -m unittest discover -s tests diff --git a/tests/test_proxy_client_install.sh b/tests/test_proxy_client_install.sh index 72fa815..cd7a08b 100755 --- a/tests/test_proxy_client_install.sh +++ b/tests/test_proxy_client_install.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# Тесты пакетного установщика proxy-client/install.sh во временном HOME. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -10,393 +11,168 @@ fail() { exit 1 } +ok() { + echo "ok - $*" +} + assert_link_points_to() { - local link_path="$1" expected_path="$2" actual_path - [ -L "$link_path" ] || fail "Expected symlink: $link_path" - actual_path="$(readlink -f "$link_path")" - [ "$actual_path" = "$expected_path" ] \ - || fail "$link_path points to $actual_path, expected $expected_path" + local link_path="$1" expected_path="$2" + [ -L "$link_path" ] || fail "нет ссылки $link_path" + [ "$(readlink -f "$link_path")" = "$expected_path" ] \ + || fail "$link_path указывает не на $expected_path" } assert_not_exists() { - local path="$1" - [ ! -e "$path" ] && [ ! -L "$path" ] || fail "Path should not exist: $path" + [ ! -e "$1" ] && [ ! -L "$1" ] || fail "путь не должен существовать: $1" } -setup_package() { - local fixture_root="$1" package_dir="$fixture_root/source with spaces/proxy-client" - mkdir -p \ - "$package_dir/.local/bin" \ - "$package_dir/.config/proxy-client" - cp -a \ - "$REPO_ROOT/proxy-client/.local/bin/proxy-client" \ - "$package_dir/.local/bin/proxy-client" - cp -a \ - "$REPO_ROOT/proxy-client/.config/proxy-client/shell.bash" \ - "$REPO_ROOT/proxy-client/.config/proxy-client/examples" \ - "$package_dir/.config/proxy-client/" - cp -a \ - "$REPO_ROOT/proxy-client/.stow-local-ignore" \ - "$REPO_ROOT/proxy-client/README.md" \ - "$REPO_ROOT/proxy-client/install.sh" \ - "$package_dir/" - printf '%s\n' 'MACHINE_CONFIG_SENTINEL' > "$package_dir/.config/proxy-client/config" - printf '%s\n' 'PRIVATE_PROFILE_SENTINEL' > "$package_dir/.config/proxy-client/default.url" - printf '%s\n' "$package_dir" -} - -run_package_install() { - local package_dir="$1" home_dir="$2" stow_bin="$3" - shift 3 - ( - cd "$TEST_TMP_ROOT" - HOME="$home_dir" STOW_BIN="$stow_bin" \ - "$package_dir/install.sh" "$@" - ) -} - -test_installs_same_public_artifacts_without_stow() { - local fixture_root package_dir home_dir output - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - - output="$( - run_package_install "$package_dir" "$home_dir" missing-stow --no-shell-setup - )" - - printf '%s' "$output" | grep -Fq 'без Stow' \ - || fail "Expected direct-symlink method in output" - printf '%s' "$output" | grep -Fq \ - 'export PATH="$HOME/.local/bin:$PATH"; source ~/.config/proxy-client/shell.bash' \ - || fail "Expected complete current-session hint with --no-shell-setup" - [ -d "$home_dir/.local/bin" ] && [ ! -L "$home_dir/.local/bin" ] \ - || fail "Expected ~/.local/bin to be a real directory" - [ -d "$home_dir/.config/proxy-client" ] \ - && [ ! -L "$home_dir/.config/proxy-client" ] \ - || fail "Expected proxy-client config home to be a real directory" - assert_link_points_to \ - "$home_dir/.local/bin/proxy-client" \ - "$package_dir/.local/bin/proxy-client" - assert_link_points_to \ - "$home_dir/.config/proxy-client/shell.bash" \ - "$package_dir/.config/proxy-client/shell.bash" - assert_link_points_to \ - "$home_dir/.config/proxy-client/examples" \ - "$package_dir/.config/proxy-client/examples" - assert_not_exists "$home_dir/.config/proxy-client/config" - assert_not_exists "$home_dir/.config/proxy-client/default.url" - assert_not_exists "$home_dir/README.md" - assert_not_exists "$home_dir/install.sh" - assert_not_exists "$home_dir/.bashrc" -} - -test_configures_bash_once_by_default() { - local fixture_root package_dir home_dir output marker_count path_count source_count - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - - run_package_install "$package_dir" "$home_dir" missing-stow >/dev/null - output="$(run_package_install "$package_dir" "$home_dir" missing-stow)" - - marker_count="$(grep -Fxc '# >>> proxy-client >>>' "$home_dir/.bashrc")" - path_count="$(grep -Fc 'export PATH="$HOME/.local/bin:$PATH"' "$home_dir/.bashrc")" - source_count="$( - grep -Fc \ - '[ -f "$HOME/.config/proxy-client/shell.bash" ] && source "$HOME/.config/proxy-client/shell.bash"' \ - "$home_dir/.bashrc" - )" - [ "$marker_count" -eq 1 ] || fail "Expected one managed Bash block" - [ "$path_count" -eq 1 ] || fail "Expected one ~/.local/bin PATH line" - [ "$source_count" -eq 1 ] || fail "Expected one shell module source line" - printf '%s' "$output" | grep -Fq 'source ~/.bashrc' \ - || fail "Expected current-session reload hint" -} - -test_installs_same_public_artifacts_with_stow() { - local fixture_root package_dir home_dir output - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - - output="$(run_package_install "$package_dir" "$home_dir" stow --no-shell-setup)" - - printf '%s' "$output" | grep -Fq 'с помощью Stow' \ - || fail "Expected Stow method in output" - [ -d "$home_dir/.local/bin" ] && [ ! -L "$home_dir/.local/bin" ] \ - || fail "Expected ~/.local/bin to be a real directory" - [ -d "$home_dir/.config/proxy-client" ] \ - && [ ! -L "$home_dir/.config/proxy-client" ] \ - || fail "Expected proxy-client config home to be a real directory" - assert_link_points_to \ - "$home_dir/.local/bin/proxy-client" \ - "$package_dir/.local/bin/proxy-client" - assert_link_points_to \ - "$home_dir/.config/proxy-client/shell.bash" \ - "$package_dir/.config/proxy-client/shell.bash" - assert_link_points_to \ - "$home_dir/.config/proxy-client/examples" \ - "$package_dir/.config/proxy-client/examples" - assert_not_exists "$home_dir/.config/proxy-client/config" - assert_not_exists "$home_dir/.config/proxy-client/default.url" - assert_not_exists "$home_dir/README.md" - assert_not_exists "$home_dir/install.sh" -} - -test_rejects_linked_parent_directories_before_changes() { - local fixture_root package_dir home_dir external_local external_config output - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - external_local="$fixture_root/external-local" - external_config="$fixture_root/external-config" - mkdir -p "$home_dir" "$external_local" "$external_config" - ln -s "$external_local" "$home_dir/.local" - ln -s "$external_config" "$home_dir/.config" - - if output="$( - run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 - )"; then - fail "Expected linked parent directories to be rejected" - fi - - printf '%s' "$output" | grep -Fq "$home_dir/.local" \ - || fail "Expected ~/.local conflict in output" - printf '%s' "$output" | grep -Fq "$home_dir/.config" \ - || fail "Expected ~/.config conflict in output" - assert_not_exists "$external_local/bin" - assert_not_exists "$external_config/proxy-client" - assert_not_exists "$home_dir/.bashrc" -} - -test_reports_all_target_conflicts_without_partial_install() { - local fixture_root package_dir home_dir other_target output - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - other_target="$fixture_root/other-target" - mkdir -p "$home_dir/.local/bin" "$home_dir/.config/proxy-client/shell.bash" - printf '%s\n' 'KEEP_CLI' > "$home_dir/.local/bin/proxy-client" - printf '%s\n' 'KEEP_TARGET' > "$other_target" - ln -s "$other_target" "$home_dir/.config/proxy-client/examples" - - if output="$( - run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 - )"; then - fail "Expected all target conflicts to be rejected" - fi - - printf '%s' "$output" | grep -Fq "$home_dir/.local/bin/proxy-client" \ - || fail "Expected CLI conflict in output" - printf '%s' "$output" | grep -Fq "$home_dir/.config/proxy-client/shell.bash" \ - || fail "Expected shell module conflict in output" - printf '%s' "$output" | grep -Fq "$home_dir/.config/proxy-client/examples" \ - || fail "Expected examples conflict in output" - grep -Fq 'KEEP_CLI' "$home_dir/.local/bin/proxy-client" \ - || fail "Expected conflicting CLI file to stay unchanged" - [ -d "$home_dir/.config/proxy-client/shell.bash" ] \ - || fail "Expected conflicting shell directory to stay unchanged" - assert_link_points_to "$home_dir/.config/proxy-client/examples" "$other_target" - assert_not_exists "$home_dir/.bashrc" -} - -test_rejects_broken_link_without_creating_other_targets() { - local fixture_root package_dir home_dir output - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir/.local/bin" - ln -s "$fixture_root/missing-cli" "$home_dir/.local/bin/proxy-client" - - if output="$( - run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 - )"; then - fail "Expected broken target link to be rejected" - fi - - printf '%s' "$output" | grep -Fq "$home_dir/.local/bin/proxy-client" \ - || fail "Expected broken CLI link in output" - [ -L "$home_dir/.local/bin/proxy-client" ] \ - || fail "Expected broken CLI link to stay unchanged" - assert_not_exists "$home_dir/.config" - assert_not_exists "$home_dir/.bashrc" -} - -test_preserves_machine_files_with_both_methods() { - local stow_bin fixture_root package_dir home_dir - for stow_bin in missing-stow stow; do - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir/.config/proxy-client" - printf '%s\n' 'HOME_CONFIG_SENTINEL' \ - > "$home_dir/.config/proxy-client/config" - printf '%s\n' 'HOME_PROFILE_SENTINEL' \ - > "$home_dir/.config/proxy-client/default.url" - - run_package_install "$package_dir" "$home_dir" "$stow_bin" \ - --no-shell-setup >/dev/null - - [ ! -L "$home_dir/.config/proxy-client/config" ] \ - || fail "Machine config must not become a symlink" - [ ! -L "$home_dir/.config/proxy-client/default.url" ] \ - || fail "Private profile must not become a symlink" - grep -Fq 'HOME_CONFIG_SENTINEL' "$home_dir/.config/proxy-client/config" \ - || fail "Machine config changed" - grep -Fq 'HOME_PROFILE_SENTINEL' "$home_dir/.config/proxy-client/default.url" \ - || fail "Private profile changed" +assert_package_links() { + local package_dir="$1" home_dir="$2" link + for link in .local/bin/proxy-client .config/proxy-client/shell.bash \ + .config/proxy-client/examples; do + assert_link_points_to "$home_dir/$link" "$package_dir/$link" done } -test_accepts_existing_links_when_method_changes() { - local fixture_root package_dir home_dir - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - - run_package_install "$package_dir" "$home_dir" missing-stow \ - --no-shell-setup >/dev/null - run_package_install "$package_dir" "$home_dir" stow \ - --no-shell-setup >/dev/null - - assert_link_points_to \ - "$home_dir/.local/bin/proxy-client" \ - "$package_dir/.local/bin/proxy-client" - assert_link_points_to \ - "$home_dir/.config/proxy-client/shell.bash" \ - "$package_dir/.config/proxy-client/shell.bash" - assert_link_points_to \ - "$home_dir/.config/proxy-client/examples" \ - "$package_dir/.config/proxy-client/examples" +# Копия пакета в пути с пробелами и машинными файлами, которые установщик +# не должен трогать. Печатает путь к пакету; HOME создаётся рядом. +new_fixture() { + local fixture package_dir + fixture="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" + package_dir="$fixture/source with spaces/proxy-client" + mkdir -p "$package_dir" "$fixture/home" + cp -a "$REPO_ROOT/proxy-client/." "$package_dir/" + printf 'MACHINE_CONFIG_SENTINEL\n' > "$package_dir/.config/proxy-client/config" + printf 'PRIVATE_PROFILE_SENTINEL\n' > "$package_dir/.config/proxy-client/default.url" + printf '%s\n' "$fixture" } -test_stow_completes_partial_correct_installation() { - local fixture_root package_dir home_dir - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - - run_package_install "$package_dir" "$home_dir" missing-stow \ - --no-shell-setup >/dev/null - rm "$home_dir/.config/proxy-client/shell.bash" - - run_package_install "$package_dir" "$home_dir" stow \ - --no-shell-setup >/dev/null - - assert_link_points_to \ - "$home_dir/.local/bin/proxy-client" \ - "$package_dir/.local/bin/proxy-client" - assert_link_points_to \ - "$home_dir/.config/proxy-client/shell.bash" \ - "$package_dir/.config/proxy-client/shell.bash" - assert_link_points_to \ - "$home_dir/.config/proxy-client/examples" \ - "$package_dir/.config/proxy-client/examples" +run_install() { + local fixture="$1" + shift + (cd "$TEST_TMP_ROOT" && HOME="$fixture/home" \ + "$fixture/source with spaces/proxy-client/install.sh" "$@") } -test_does_not_duplicate_full_dotfiles_shell_setup() { - local fixture_root package_dir home_dir source_count - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - printf '%s\n' \ - '[ -f ~/.config/proxy-client/shell.bash ] && source ~/.config/proxy-client/shell.bash' \ - > "$home_dir/.bash_custom" - printf '%s\n' '[ -f ~/.bash_custom ] && source ~/.bash_custom' \ - > "$home_dir/.bashrc" - - run_package_install "$package_dir" "$home_dir" missing-stow >/dev/null - - grep -Fq '# >>> proxy-client >>>' "$home_dir/.bashrc" \ - || fail "Expected managed PATH block with full dotfiles" - grep -Fq 'export PATH="$HOME/.local/bin:$PATH"' "$home_dir/.bashrc" \ - || fail "Expected ~/.local/bin setup with full dotfiles" - source_count="$(grep -Fc 'proxy-client/shell.bash' "$home_dir/.bashrc" || true)" - [ "$source_count" -eq 0 ] \ - || fail "Standalone block duplicated full dotfiles module source" +home_snapshot() { + find "$1" -printf '%p %y %l %s %T@\n' | sort } -test_rejects_damaged_managed_bash_block_before_changes() { - local fixture_root package_dir home_dir output - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - printf '%s\n' '# >>> proxy-client >>>' > "$home_dir/.bashrc" +test_fresh_install() { + local fixture home_dir package_dir output + fixture="$(new_fixture)" + home_dir="$fixture/home" + package_dir="$fixture/source with spaces/proxy-client" - if output="$( - run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 - )"; then - fail "Expected damaged managed Bash block to be rejected" + run_install "$fixture" >/dev/null + + assert_package_links "$package_dir" "$home_dir" + [ -x "$home_dir/.local/bin/proxy-client" ] || fail "CLI не исполняемый" + assert_not_exists "$home_dir/.config/proxy-client/config" + assert_not_exists "$home_dir/.config/proxy-client/default.url" + output="$( + env -i HOME="$home_dir" PATH=/usr/bin:/bin TERM=dumb bash -ic ' + command -v proxy-client && declare -F proxy_on + ' 2>/dev/null + )" + [ "$output" = "$home_dir/.local/bin/proxy-client"$'\n'proxy_on ] \ + || fail "новый Bash не нашёл proxy-client и proxy_on: $output" + ok "свежая установка: три ссылки и рабочий блок в ~/.bashrc" +} + +test_repeat_changes_nothing() { + local fixture home_dir before + fixture="$(new_fixture)" + home_dir="$fixture/home" + run_install "$fixture" >/dev/null + before="$(home_snapshot "$home_dir")" + + run_install "$fixture" >/dev/null + + [ "$(home_snapshot "$home_dir")" = "$before" ] \ + || fail "повторный запуск изменил HOME" + ok "повторный запуск ничего не меняет" +} + +test_conflicts_block_whole_install() { + local fixture home_dir output + fixture="$(new_fixture)" + home_dir="$fixture/home" + mkdir -p "$home_dir/.local/bin" "$home_dir/.config/proxy-client" + printf 'KEEP_CLI\n' > "$home_dir/.local/bin/proxy-client" + ln -s "$fixture/missing" "$home_dir/.config/proxy-client/examples" + + if output="$(run_install "$fixture" 2>&1)"; then + fail "установка прошла поверх чужих файлов" fi - printf '%s' "$output" | grep -Fq "$home_dir/.bashrc" \ - || fail "Expected ~/.bashrc conflict in output" - [ "$(wc -l < "$home_dir/.bashrc")" -eq 1 ] \ - || fail "Expected damaged Bash block to stay unchanged" - assert_not_exists "$home_dir/.local" - assert_not_exists "$home_dir/.config" -} - -test_does_not_duplicate_existing_local_bin_path() { - local fixture_root package_dir home_dir path_count source_count - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - printf '%s\n' 'export PATH="$HOME/.local/bin:$PATH"' > "$home_dir/.bashrc" - - run_package_install "$package_dir" "$home_dir" missing-stow >/dev/null - - path_count="$(grep -Fc 'export PATH="$HOME/.local/bin:$PATH"' "$home_dir/.bashrc")" - source_count="$(grep -Fc 'proxy-client/shell.bash' "$home_dir/.bashrc")" - [ "$path_count" -eq 1 ] || fail "Existing ~/.local/bin PATH line was duplicated" - [ "$source_count" -eq 1 ] || fail "Expected shell module source in managed block" -} - -test_rejects_incomplete_source_before_changes() { - local fixture_root package_dir home_dir output - fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" - package_dir="$(setup_package "$fixture_root")" - home_dir="$fixture_root/home" - mkdir -p "$home_dir" - rm "$package_dir/.config/proxy-client/shell.bash" - - if output="$( - run_package_install "$package_dir" "$home_dir" missing-stow 2>&1 - )"; then - fail "Expected incomplete source package to be rejected" - fi - - printf '%s' "$output" | grep -Fq 'исходного пакета' \ - || fail "Expected source package error" - assert_not_exists "$home_dir/.local" - assert_not_exists "$home_dir/.config" + printf '%s' "$output" | grep -Fq "$home_dir/.local/bin/proxy-client" \ + || fail "не назван конфликт CLI" + printf '%s' "$output" | grep -Fq "$home_dir/.config/proxy-client/examples" \ + || fail "не названа битая ссылка" + grep -Fqx KEEP_CLI "$home_dir/.local/bin/proxy-client" \ + || fail "чужой CLI изменён" + [ "$(readlink "$home_dir/.config/proxy-client/examples")" = "$fixture/missing" ] \ + || fail "битая ссылка изменена" + assert_not_exists "$home_dir/.config/proxy-client/shell.bash" assert_not_exists "$home_dir/.bashrc" + ok "все конфликты названы сразу, частичной установки нет" } -main() { - test_installs_same_public_artifacts_without_stow - test_configures_bash_once_by_default - test_installs_same_public_artifacts_with_stow - test_rejects_linked_parent_directories_before_changes - test_reports_all_target_conflicts_without_partial_install - test_rejects_broken_link_without_creating_other_targets - test_preserves_machine_files_with_both_methods - test_accepts_existing_links_when_method_changes - test_stow_completes_partial_correct_installation - test_does_not_duplicate_full_dotfiles_shell_setup - test_rejects_damaged_managed_bash_block_before_changes - test_does_not_duplicate_existing_local_bin_path - test_rejects_incomplete_source_before_changes - echo "All standalone proxy-client install tests passed" +test_keeps_machine_files() { + local fixture home_dir + fixture="$(new_fixture)" + home_dir="$fixture/home" + mkdir -p "$home_dir/.config/proxy-client" + printf 'HOME_CONFIG\n' > "$home_dir/.config/proxy-client/config" + printf 'HOME_PROFILE\n' > "$home_dir/.config/proxy-client/default.url" + + run_install "$fixture" --no-shell-setup >/dev/null + + [ ! -L "$home_dir/.config/proxy-client/config" ] || fail "config стал ссылкой" + [ ! -L "$home_dir/.config/proxy-client/default.url" ] \ + || fail "default.url стал ссылкой" + grep -Fqx HOME_CONFIG "$home_dir/.config/proxy-client/config" \ + || fail "машинный config изменён" + grep -Fqx HOME_PROFILE "$home_dir/.config/proxy-client/default.url" \ + || fail "default.url изменён" + ok "машинные config и default.url не трогаются" } -main "$@" +test_no_shell_setup() { + local fixture home_dir output + fixture="$(new_fixture)" + home_dir="$fixture/home" + + output="$(run_install "$fixture" --no-shell-setup)" + + assert_not_exists "$home_dir/.bashrc" + printf '%s' "$output" | grep -Fq 'proxy-client/shell.bash' \ + || fail "нет подсказки для текущей сессии" + ok "--no-shell-setup не трогает ~/.bashrc" +} + +test_linked_parent_directories() { + local fixture home_dir package_dir + fixture="$(new_fixture)" + home_dir="$fixture/home" + package_dir="$fixture/source with spaces/proxy-client" + mkdir -p "$fixture/external-local" "$fixture/external-config" + ln -s "$fixture/external-local" "$home_dir/.local" + ln -s "$fixture/external-config" "$home_dir/.config" + + run_install "$fixture" --no-shell-setup >/dev/null + + assert_package_links "$package_dir" "$home_dir" + [ -L "$fixture/external-local/bin/proxy-client" ] \ + || fail "ссылка не попала в каталог, на который указывает ~/.local" + ok "каталоги ~/.local и ~/.config могут быть ссылками" +} + +test_fresh_install +test_repeat_changes_nothing +test_conflicts_block_whole_install +test_keeps_machine_files +test_no_shell_setup +test_linked_parent_directories +echo "PASS: тесты установщика proxy-client" diff --git a/tests/test_proxy_launchers.sh b/tests/test_proxy_launchers.sh index abb3171..249160c 100755 --- a/tests/test_proxy_launchers.sh +++ b/tests/test_proxy_launchers.sh @@ -1,12 +1,19 @@ #!/usr/bin/env bash +# Тесты Bash-функций proxy-client: вместо настоящего CLI — поддельный +# исполняемый файл в PROXY_CLIENT_BIN. +# Строчные proxy-переменные задаёт proxy_on, shellcheck этого не видит. +# shellcheck disable=SC2154 set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FUNCTIONS_FILE="$REPO_ROOT/bash/.bash_functions" CUSTOM_FILE="$REPO_ROOT/bash/.bash_custom" SHELL_MODULE="$REPO_ROOT/proxy-client/.config/proxy-client/shell.bash" +TEST_ROOT="$(mktemp -d /tmp/proxy-launchers-test.XXXXXX)" +trap 'rm -rf "$TEST_ROOT"' EXIT -pass_count=0 +PROXY_KEYS=(HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY + http_proxy https_proxy all_proxy no_proxy) fail() { echo "FAIL: $*" >&2 @@ -14,177 +21,124 @@ fail() { } ok() { - pass_count=$((pass_count + 1)) echo "ok - $*" } assert_eq() { - local expected="$1" actual="$2" message="$3" - [ "$expected" = "$actual" ] || fail "$message (ожидалось '$expected', получено '$actual')" + [ "$1" = "$2" ] || fail "$3 (ожидалось '$1', получено '$2')" } -valid_env() { - cat <<'EOF' -HTTP_PROXY=http://proxy.test:1234 +# Поддельный CLI: на "env" печатает $FAKE_ENV_OUTPUT, на остальное — свои +# аргументы по одному в строке; завершается с кодом $FAKE_RC. +PROXY_CLIENT_BIN="$TEST_ROOT/proxy-client" +cat > "$PROXY_CLIENT_BIN" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = env ]; then + printf '%s\n' "$FAKE_ENV_OUTPUT" +else + printf '<%s>\n' "$@" +fi +exit "${FAKE_RC:-0}" +EOF +chmod +x "$PROXY_CLIENT_BIN" +export PROXY_CLIENT_BIN + +VALID_ENV='HTTP_PROXY=http://proxy.test:1234 HTTPS_PROXY=http://proxy.test:1234 ALL_PROXY=socks5://proxy.test:1234 -NO_PROXY=proxy.test,localhost,127.0.0.1,::1,[::1],.example.test +NO_PROXY=proxy.test,localhost,.example.test http_proxy=http://proxy.test:1234 https_proxy=http://proxy.test:1234 all_proxy=socks5://proxy.test:1234 -no_proxy=proxy.test,localhost,127.0.0.1,::1,[::1],.example.test -EOF -} +no_proxy=proxy.test,localhost,.example.test' -assert_five_proxy_commands() { - local command_name - for command_name in proxy_on proxy_off pclaude pcodex myip; do - declare -F "$command_name" >/dev/null || fail "нет функции $command_name" +# shellcheck source=/dev/null +source "$SHELL_MODULE" + +# proxy_on применяет все восемь переменных. +( + unset "${PROXY_KEYS[@]}" + FAKE_ENV_OUTPUT="$VALID_ENV" proxy_on >/dev/null + assert_eq http://proxy.test:1234 "$HTTP_PROXY" HTTP_PROXY + assert_eq http://proxy.test:1234 "$https_proxy" https_proxy + assert_eq socks5://proxy.test:1234 "$ALL_PROXY" ALL_PROXY + assert_eq proxy.test,localhost,.example.test "$no_proxy" no_proxy + for key in "${PROXY_KEYS[@]}"; do + [ -n "$(printenv "$key")" ] || fail "$key не экспортирован" done -} +) +ok "proxy_on применяет восемь переменных" -# Самостоятельный модуль предоставляет весь публичный Bash-интерфейс. -( - source "$SHELL_MODULE" - assert_five_proxy_commands -) && ok "самостоятельное подключение предоставляет пять proxy-команд" - -# Обычная .bash_custom независимо подключает общие функции и proxy-модуль. -( - fixture="$(mktemp -d /tmp/proxy-bash-custom-test.XXXXXX)" - mkdir -p "$fixture/.config/proxy-client" - ln -s "$CUSTOM_FILE" "$fixture/.bash_custom" - ln -s "$FUNCTIONS_FILE" "$fixture/.bash_functions" - ln -s "$SHELL_MODULE" "$fixture/.config/proxy-client/shell.bash" - output="$( - HOME="$fixture" bash --noprofile --norc -ic ' - source "$HOME/.bash_custom" - for name in proxy_on proxy_off pclaude pcodex myip update_hproxy; do - declare -F "$name" >/dev/null || exit 1 - done - printf ready - ' 2>/dev/null - )" - assert_eq ready "$output" ".bash_custom не подключил оба независимых модуля" -) && ok "обычное подключение предоставляет proxy-команды и update_hproxy" - -# proxy_on применяет восемь известных переменных и не запрашивает IP. -( - source "$SHELL_MODULE" - _proxy_cli() { - [ "$1" = env ] || return 99 - valid_env - } - curl() { fail "proxy_on не должен вызывать curl"; } - unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \ - http_proxy https_proxy all_proxy no_proxy || true - proxy_on >/dev/null - assert_eq "http://proxy.test:1234" "$HTTP_PROXY" "HTTP_PROXY" - assert_eq "$HTTP_PROXY" "$http_proxy" "http_proxy" - assert_eq "http://proxy.test:1234" "$HTTPS_PROXY" "HTTPS_PROXY" - assert_eq "$HTTPS_PROXY" "$https_proxy" "https_proxy" - assert_eq "socks5://proxy.test:1234" "$ALL_PROXY" "ALL_PROXY" - assert_eq "$ALL_PROXY" "$all_proxy" "all_proxy" - assert_eq "proxy.test,localhost,127.0.0.1,::1,[::1],.example.test" "$NO_PROXY" "NO_PROXY" - assert_eq "$NO_PROXY" "$no_proxy" "no_proxy" -) && ok "proxy_on применяет известные ключи без запроса IP" - -# Неизвестная строка в машинном выводе оставляет прежнее окружение целиком. -unknown_output="$(valid_env; printf 'EXTRA_PROXY=bad\n')" -for bad_output in \ - "$unknown_output" \ - "BROKEN-LINE"; do +# Ошибка CLI или непонятный вывод не меняют окружение ни на одну переменную. +for case_spec in "1|$VALID_ENV" "0|$VALID_ENV +EXTRA_PROXY=bad" "0|BROKEN-LINE"; do ( - source "$SHELL_MODULE" - export HTTP_PROXY=old-http HTTPS_PROXY=old-https ALL_PROXY=old-all NO_PROXY=old-no - export http_proxy=old-http-lower https_proxy=old-https-lower - export all_proxy=old-all-lower no_proxy=old-no-lower - _proxy_cli() { printf '%s\n' "$bad_output"; } - if proxy_on >/dev/null 2>&1; then - fail "proxy_on принял неверный вывод" + for key in "${PROXY_KEYS[@]}"; do export "$key=old"; done + if FAKE_RC="${case_spec%%|*}" FAKE_ENV_OUTPUT="${case_spec#*|}" \ + proxy_on >/dev/null 2>&1; then + fail "proxy_on принял ошибочный ответ: ${case_spec#*|}" fi - assert_eq old-http "$HTTP_PROXY" "HTTP_PROXY изменился при ошибке" - assert_eq old-https "$HTTPS_PROXY" "HTTPS_PROXY изменился при ошибке" - assert_eq old-all "$ALL_PROXY" "ALL_PROXY изменился при ошибке" - assert_eq old-no "$NO_PROXY" "NO_PROXY изменился при ошибке" - assert_eq old-http-lower "$http_proxy" "http_proxy изменился при ошибке" - assert_eq old-https-lower "$https_proxy" "https_proxy изменился при ошибке" - assert_eq old-all-lower "$all_proxy" "all_proxy изменился при ошибке" - assert_eq old-no-lower "$no_proxy" "no_proxy изменился при ошибке" - ) || fail "атомарность нарушена для неверного вывода" + for key in "${PROXY_KEYS[@]}"; do + assert_eq old "${!key}" "$key изменился после ошибки" + done + ) done -ok "неизвестная env-строка отклоняется без частичного изменения окружения" +ok "неуспешный env не меняет окружение" -# Даже полный stdout не применяется, если CLI завершился с ошибкой. +# proxy_off очищает оба регистра. ( - source "$SHELL_MODULE" - export HTTP_PROXY=old HTTPS_PROXY=old ALL_PROXY=old NO_PROXY=old - export http_proxy=old https_proxy=old all_proxy=old no_proxy=old - _proxy_cli() { - valid_env - return 1 - } - if proxy_on >/dev/null 2>&1; then - fail "proxy_on принял вывод неуспешного CLI" - fi - for key in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \ - http_proxy https_proxy all_proxy no_proxy; do - assert_eq old "${!key}" "$key изменился после ошибки CLI" + for key in "${PROXY_KEYS[@]}"; do export "$key=x"; done + proxy_off >/dev/null + for key in "${PROXY_KEYS[@]}"; do + [ -z "${!key+set}" ] || fail "$key не очищен" done -) && ok "stdout неуспешного env не изменяет окружение" +) +ok "proxy_off очищает восемь переменных" # Обёртки передают CLI команду, аргументы и код возврата без искажений. ( - source "$SHELL_MODULE" - _proxy_cli() { - printf '<%s>\n' "$@" - return 42 - } + export FAKE_RC=42 set +e output="$(pclaude "a b" -- --flag)" rc=$? set -e assert_eq 42 "$rc" "pclaude не сохранил код возврата" - assert_eq $'\n<-->\n\n\n<-->\n<--flag>' "$output" "pclaude исказил аргументы" + assert_eq $'\n<-->\n\n\n<-->\n<--flag>' "$output" \ + "pclaude исказил аргументы" set +e output="$(pcodex --search)" rc=$? set -e assert_eq 42 "$rc" "pcodex не сохранил код возврата" - assert_eq $'\n<-->\n\n<--search>' "$output" "pcodex исказил аргументы" -) && ok "pclaude и pcodex делегируют run и сохраняют аргументы/код" + assert_eq $'\n<-->\n\n<--search>' "$output" \ + "pcodex исказил аргументы" +) +ok "pclaude и pcodex передают аргументы и код выхода" -# proxy_off очищает оба регистра и не смешивает действие с диагностикой. +# .bash_custom подключает и общие функции, и proxy-модуль. ( - source "$SHELL_MODULE" - export HTTP_PROXY=x HTTPS_PROXY=x ALL_PROXY=x NO_PROXY=x - export http_proxy=x https_proxy=x all_proxy=x no_proxy=x - curl() { fail "proxy_off не должен вызывать curl"; } - proxy_off >/dev/null - for key in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \ - http_proxy https_proxy all_proxy no_proxy; do - [ -z "${!key+x}" ] || fail "$key не очищен" - done -) && ok "proxy_off очищает восемь переменных без запроса IP" - -# Общий файл больше не владеет proxy-интерфейсом, но сохраняет update_hproxy. -( - source "$FUNCTIONS_FILE" - declare -F update_hproxy >/dev/null || fail "update_hproxy потерян" - for name in proxy_on proxy_off pclaude pcodex myip _proxy_run _proxy_cli; do - if declare -F "$name" >/dev/null; then - fail "$name всё ещё определён в bash/.bash_functions" - fi - done -) && ok "владение proxy-функциями перенесено, update_hproxy сохранён" + home="$TEST_ROOT/home" + mkdir -p "$home/.config/proxy-client" + ln -s "$CUSTOM_FILE" "$home/.bash_custom" + ln -s "$FUNCTIONS_FILE" "$home/.bash_functions" + ln -s "$SHELL_MODULE" "$home/.config/proxy-client/shell.bash" + output="$( + HOME="$home" bash --noprofile --norc -ic ' + source "$HOME/.bash_custom" + declare -F proxy_on update_hproxy >/dev/null && printf ready + ' 2>/dev/null + )" + assert_eq ready "$output" ".bash_custom не подключил модули" +) +ok ".bash_custom подключает общие функции и proxy-модуль" if command -v shellcheck >/dev/null 2>&1; then - shellcheck -s bash "$FUNCTIONS_FILE" "$CUSTOM_FILE" "$SHELL_MODULE" "$0" \ + shellcheck -s bash "$SHELL_MODULE" "$0" \ || fail "shellcheck нашёл проблемы" ok "shellcheck чист" else echo "skip - shellcheck не установлен" fi -echo "PASS: $pass_count проверок пройдено" +echo "PASS: тесты Bash-функций proxy-client" From a5db265f47b741343a0d9ac56c7362cfe266f44a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 09:34:57 -0400 Subject: [PATCH 5/9] =?UTF-8?q?refactor(proxy-client):=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=B8=D0=B7=D0=B1=D1=8B=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=83=D1=8E=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D1=83?= =?UTF-8?q?=20=D0=B8=D0=B7=20CLI=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Проверка «тот ли процесс»: /proc/PID/exe для Xray и cmdline для супервизора вместо boot_id, времени старта и разбора argv; нет больше состояния «не удалось подтвердить, удалите файлы вручную». - Установка Xray: SHA-256 и атомарная замена остаются; без ограничений размера, строгого разбора .dgst и блокировки на время установки. - Разбор ссылки: extra — обычный json.loads; allowInsecure/insecure игнорируются с предупреждением. - Ошибки о файлах называют полный путь и готовую команду (chmod 600 для профиля, rm для битого state.json). - Тесты: разбор ссылок — табличные без запуска процессов, по одному запуску на сценарий управляемого Xray; без подмены внутренних функций. Код 1569 → 925 строк, тесты 2479 → 895, прогон 63 → 9 с. Co-Authored-By: Claude Opus 5.5 (1M context) --- proxy-client/.local/bin/proxy-client | 1414 +++++++---------------- proxy-client/README.md | 61 +- tests/test_proxy_client.py | 1571 +++++++------------------- tests/test_proxy_client_managed.py | 1289 ++++----------------- 4 files changed, 1064 insertions(+), 3271 deletions(-) diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index c2a0c7a..1ca3079 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -19,14 +19,13 @@ import json import os import platform import re +import shlex import shutil import signal import socket -import stat import subprocess import tempfile import time -import urllib.error import urllib.request import uuid import zipfile @@ -46,16 +45,12 @@ DEFAULT_NO_PROXY_EXTRA = ( ".dementev.space", ) XRAY_VERSION = "26.3.27" -VERSION_LINE = re.compile( - r"^Xray ({0})(?: .*)?$".format(re.escape(XRAY_VERSION)) -) +VERSION_LINE = re.compile(r"Xray {}(?: .*)?".format(re.escape(XRAY_VERSION))) XRAY_RELEASE_BASE_URL = ( "https://github.com/XTLS/Xray-core/releases/download/v{}/".format(XRAY_VERSION) ) -MAX_ARCHIVE_BYTES = 64 * 1024 * 1024 -MAX_DIGEST_BYTES = 4 * 1024 -MAX_XRAY_BYTES = 64 * 1024 * 1024 -STREAM_CHUNK_BYTES = 64 * 1024 +# Сколько секунд ждать остановки Xray, прежде чем добить его SIGKILL. +STOP_TIMEOUT = 5 QUERY_KEYS = { "type", "security", @@ -79,28 +74,12 @@ QUERY_KEYS = { "insecure", } ALPN_VALUES = {"http/1.1", "h2", "h3"} -STATE_KEYS = { - "supervisor_pid", - "xray_pid", - "xray_path", - "config_path", - "address", - "port", - "supervisor_path", - "supervisor_starttime", - "xray_starttime", - "boot_id", -} class ProxyError(Exception): pass -class StartLockBusyError(ProxyError): - pass - - class ProxyConfig: def __init__(self, proxy_type, name, address, port, no_proxy_extra): self.proxy_type = proxy_type @@ -111,13 +90,14 @@ class ProxyConfig: class VlessProfile: - def __init__(self, server, port, profile_id, query, alpn, extra): + def __init__(self, server, port, profile_id, query, alpn, extra, insecure): self.server = server self.port = port self.profile_id = profile_id self.query = query self.alpn = alpn self.extra = extra + self.insecure = insecure @property def transport(self): @@ -128,25 +108,15 @@ class VlessProfile: return self.query["security"] -def config_path(): - config_home = os.environ.get("XDG_CONFIG_HOME") - if config_home: - return Path(config_home).expanduser() / "proxy-client/config" - return Path.home() / ".config/proxy-client/config" - - -def proxy_client_config_home(): - config_home = os.environ.get("XDG_CONFIG_HOME") - if config_home: - return Path(config_home).expanduser() / "proxy-client" - return Path.home() / ".config/proxy-client" +def config_home(): + root = os.environ.get("XDG_CONFIG_HOME") + return (Path(root).expanduser() if root else Path.home() / ".config") / "proxy-client" def state_home(): - state_root = os.environ.get("XDG_STATE_HOME") - if state_root: - return Path(state_root).expanduser() / "proxy-client" - return Path.home() / ".local/state/proxy-client" + root = os.environ.get("XDG_STATE_HOME") + base = Path(root).expanduser() if root else Path.home() / ".local/state" + return base / "proxy-client" def state_path(): @@ -157,19 +127,15 @@ def generated_config_path(): return state_home() / "client.json" -def lock_path(): - return state_home() / "lock" - - def validate_address(value): try: - parsed = ipaddress.ip_address(value) + ipaddress.ip_address(value) except ValueError: pass else: if "%" in value: raise ProxyError("поле address не поддерживает IPv6 zone ID") - return parsed + return if ( not value or not value.isascii() @@ -177,90 +143,52 @@ def validate_address(value): or any(separator in value for separator in ("://", "@", "/", "[", "]", "%")) ): raise ProxyError("поле address должно быть IP-адресом или простым ASCII-именем") - return None - - -def parse_no_proxy_extra(value): - if value == "": - return () - items = tuple(item.strip() for item in value.split(",")) - if any(not item for item in items): - raise ProxyError("no_proxy_extra содержит пустой элемент") - return items - - -def default_config(): - return ProxyConfig( - "external", - DEFAULT_NAME, - DEFAULT_ADDRESS, - DEFAULT_PORT, - DEFAULT_NO_PROXY_EXTRA, - ) def load_config(): - path = config_path() + path = config_home() / "config" if not path.exists(): if path.is_symlink(): - raise ProxyError("машинный конфиг ссылается на отсутствующий файл: {}".format(path)) - return default_config() - if not path.is_file(): - raise ProxyError("машинный конфиг не является обычным файлом: {}".format(path)) - + raise ProxyError("машинный конфиг {} ссылается на отсутствующий файл".format(path)) + return ProxyConfig( + "external", DEFAULT_NAME, DEFAULT_ADDRESS, DEFAULT_PORT, DEFAULT_NO_PROXY_EXTRA + ) try: - text = path.read_text(encoding="utf-8") - except OSError as error: - raise ProxyError("не удалось прочитать машинный конфиг {}: {}".format(path, error)) + return parse_config(path.read_text(encoding="utf-8")) + except (OSError, ValueError, configparser.Error, ProxyError) as error: + raise ProxyError("машинный конфиг {}: {}".format(path, error)) - parser = configparser.ConfigParser( - interpolation=None, - strict=True, - allow_no_value=False, - empty_lines_in_values=False, - ) - try: - parser.read_string(text, source=str(path)) - except configparser.Error as error: - raise ProxyError("неверный машинный INI: {}".format(error)) +def parse_config(text): + parser = configparser.ConfigParser(interpolation=None, empty_lines_in_values=False) + parser.read_string(text, source="config") if parser.sections() != ["proxy"]: - raise ProxyError("машинный INI должен содержать только секцию [proxy]") - + raise ProxyError("нужна ровно одна секция [proxy]") section = parser["proxy"] - actual_keys = set(section.keys()) - if actual_keys != CONFIG_KEYS: - missing = sorted(CONFIG_KEYS - actual_keys) - unknown = sorted(actual_keys - CONFIG_KEYS) + keys = set(section) + if keys != CONFIG_KEYS: details = [] - if missing: - details.append("нет ключей: {}".format(", ".join(missing))) - if unknown: - details.append("неизвестные ключи: {}".format(", ".join(unknown))) + if CONFIG_KEYS - keys: + details.append("нет ключей: {}".format(", ".join(sorted(CONFIG_KEYS - keys)))) + if keys - CONFIG_KEYS: + details.append("неизвестные ключи: {}".format(", ".join(sorted(keys - CONFIG_KEYS)))) raise ProxyError("неверные ключи [proxy] ({})".format("; ".join(details))) - values = {key: section.get(key, raw=True) for key in CONFIG_KEYS} - - proxy_type = values["type"].strip() + proxy_type = section["type"] if proxy_type not in ("external", "xray"): raise ProxyError("поле type должно быть external или xray") - name = values["name"].strip() - address = values["address"].strip() - if not name: + if not section["name"]: raise ProxyError("поле name не должно быть пустым") - if not address: - raise ProxyError("поле address не должно быть пустым") + address = section["address"] validate_address(address) - - port_text = values["port"].strip() - if not re.fullmatch(r"[0-9]+", port_text): + if not re.fullmatch(r"[0-9]+", section["port"]) or not 1 <= int(section["port"]) <= 65535: raise ProxyError("поле port должно быть целым числом от 1 до 65535") - port = int(port_text) - if not 1 <= port <= 65535: - raise ProxyError("поле port должно быть в диапазоне 1–65535") - - no_proxy_extra = parse_no_proxy_extra(values["no_proxy_extra"]) - return ProxyConfig(proxy_type, name, address, port, no_proxy_extra) + no_proxy_extra = () + if section["no_proxy_extra"]: + no_proxy_extra = tuple(item.strip() for item in section["no_proxy_extra"].split(",")) + if not all(no_proxy_extra): + raise ProxyError("no_proxy_extra содержит пустой элемент") + return ProxyConfig(proxy_type, section["name"], address, int(section["port"]), no_proxy_extra) def preflight_timeout(): @@ -274,24 +202,21 @@ def preflight_timeout(): return timeout -def check_reachable(config, timeout): - connection = None +def check_reachable(config): try: - connection = socket.create_connection((config.address, config.port), timeout=timeout) + socket.create_connection((config.address, config.port), timeout=preflight_timeout()).close() except OSError as error: raise ProxyError( "прокси {} ({}:{}) недоступен: {}".format( config.name, config.address, config.port, error ) ) - finally: - if connection is not None: - connection.close() -def check_endpoint(address, port, name="управляемый Xray"): - config = ProxyConfig("xray", name, address, port, ()) - check_reachable(config, preflight_timeout()) +def check_proxy(config): + if config.proxy_type == "xray": + require_supervised_xray() + check_reachable(config) def proxy_host_for_url(address): @@ -325,49 +250,33 @@ def environment_values(config): def private_directory(path): path.mkdir(parents=True, exist_ok=True, mode=0o700) - try: - path.chmod(0o700) - except OSError as error: - raise ProxyError("не удалось закрыть служебный каталог: {}".format(error)) + path.chmod(0o700) def write_private_json(path, value): private_directory(path.parent) - try: - descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - json.dump( - value, - stream, - ensure_ascii=False, - sort_keys=True, - allow_nan=False, - ) - stream.write("\n") - path.chmod(0o600) - except (OSError, ValueError) as error: - raise ProxyError("не удалось записать закрытый служебный файл: {}".format(error)) + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False) + stream.write("\n") + path.chmod(0o600) def read_profile(path): - try: - resolved = path.expanduser().resolve(strict=True) - file_stat = resolved.stat() - except (OSError, RuntimeError) as error: - raise ProxyError("не удалось открыть файл профиля: {}".format(error)) - if not stat.S_ISREG(file_stat.st_mode): - raise ProxyError("файл профиля должен быть обычным файлом") - if file_stat.st_mode & 0o077: + # Права проверяем у конечного файла: у симлинка они ничего не значат. + resolved = path.expanduser().resolve(strict=True) + if resolved.stat().st_mode & 0o077: raise ProxyError( - "конечный файл профиля доступен группе или другим пользователям; выполните chmod 600" + "файл профиля {} доступен другим пользователям; выполните: chmod 600 {}".format( + resolved, shlex.quote(str(resolved)) + ) ) - try: - text = resolved.read_text(encoding="utf-8") - except OSError as error: - raise ProxyError("не удалось прочитать файл профиля: {}".format(error)) - lines = [line.strip() for line in text.splitlines() if line.strip()] + lines = [line.strip() for line in resolved.read_text(encoding="utf-8").splitlines()] + lines = [line for line in lines if line] if len(lines) != 1: - raise ProxyError("файл профиля должен содержать ровно одну непустую VLESS-ссылку") + raise ProxyError( + "файл профиля {} должен содержать ровно одну непустую VLESS-ссылку".format(resolved) + ) return lines[0] @@ -393,48 +302,15 @@ def parse_query(raw_query): def required(query, *keys): - missing = [key for key in keys if not query.get(key)] - if missing: + if not all(query.get(key) for key in keys): raise ProxyError("в профиле отсутствуют обязательные параметры") -def parse_boolean_fields(query): - values = [] - for key in ("allowInsecure", "insecure"): - if key in query: - if query[key] not in ("true", "false"): - raise ProxyError("legacy-поля принимают только true или false") - values.append(query[key] == "true") - if len(values) == 2 and values[0] != values[1]: - raise ProxyError("allowInsecure и insecure должны совпадать") - return values[0] if values else False - - -def parse_json_object(value): - def reject_duplicates(pairs): - result = {} - for key, item in pairs: - if key in result: - raise ProxyError("extra содержит повтор ключа JSON") - result[key] = item - return result - - def reject_constant(_constant): - raise ProxyError("extra содержит константу вне стандарта JSON") - - try: - parsed = json.loads( - value, - object_pairs_hook=reject_duplicates, - parse_constant=reject_constant, - ) - except ProxyError: - raise - except (TypeError, ValueError): - raise ProxyError("extra должен быть корректным JSON-объектом") - if not isinstance(parsed, dict): - raise ProxyError("extra должен быть JSON-объектом") - return parsed +def list_value(value): + items = value.split(",") + if not all(items): + raise ProxyError("списочный параметр содержит пустой элемент") + return items def parse_vless(url): @@ -478,14 +354,17 @@ def parse_vless(url): raise ProxyError("поддерживается только метаданные hiddify=1") if "core" in query and query["core"] != "xray": raise ProxyError("поддерживается только метаданные core=xray") - legacy_insecure = parse_boolean_fields(query) + # Xray 26.3.27 больше не умеет отключать проверку сертификата: поля + # принимаем, но не переносим. + insecure = any( + query.get(key, "").lower() in ("1", "true") for key in ("allowInsecure", "insecure") + ) alpn = None extra = None if query["type"] == "tcp" and query["security"] == "reality": required(query, "flow", "pbk", "sid") - forbidden = {"path", "mode", "extra", "pcs", "vcn", "core", "allowInsecure", "insecure"} - if forbidden.intersection(query): + if {"path", "mode", "extra", "pcs", "vcn", "core"}.intersection(query): raise ProxyError("параметры профиля несовместимы с TCP/REALITY") elif query["type"] == "xhttp" and query["security"] == "tls": required(query, "host", "path") @@ -500,23 +379,19 @@ def parse_vless(url): list_value(query["vcn"]) if "alpn" in query: alpn = list_value(query["alpn"]) - if not alpn or any(item not in ALPN_VALUES for item in alpn): + if any(item not in ALPN_VALUES for item in alpn): raise ProxyError("профиль содержит неподдерживаемый ALPN") if "extra" in query: - extra = parse_json_object(query["extra"]) + try: + extra = json.loads(query["extra"]) + except ValueError: + extra = None + if not isinstance(extra, dict): + raise ProxyError("extra должен быть JSON-объектом") else: raise ProxyError("поддерживаются только TCP/REALITY и XHTTP/TLS") - profile = VlessProfile(server, port, profile_id, query, alpn, extra) - profile.legacy_insecure = legacy_insecure - return profile - - -def list_value(value): - items = value.split(",") - if any(not item for item in items): - raise ProxyError("списочный параметр содержит пустой элемент") - return items + return VlessProfile(server, port, profile_id, query, alpn, extra, insecure) def build_xray_config(profile, local_address, local_port): @@ -585,30 +460,25 @@ def build_xray_config(profile, local_address, local_port): def resolve_xray(): found = shutil.which("xray") if found is None: - raise ProxyError("xray не найден в PATH; сначала выполните proxy-client install") - try: - return str(Path(found).resolve(strict=True)) - except OSError as error: - raise ProxyError("не удалось разрешить путь к xray: {}".format(error)) + raise ProxyError("xray не найден в PATH; установите его: proxy-client install") + return str(Path(found).resolve(strict=True)) -def xray_version(path): +def check_xray_version(path): try: completed = subprocess.run( [path, "version"], text=True, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stderr=subprocess.DEVNULL, timeout=5, check=False, ) except (OSError, subprocess.TimeoutExpired): - raise ProxyError("не удалось безопасно определить версию выбранного xray") - first_line = completed.stdout.splitlines()[0] if completed.stdout.splitlines() else "" - match = VERSION_LINE.fullmatch(first_line) - if completed.returncode != 0 or match is None: - raise ProxyError("выбранный xray не сообщил точно поддерживаемую версию") - return match.group(1) + completed = None + lines = completed.stdout.splitlines() if completed else [] + if not lines or completed.returncode != 0 or not VERSION_LINE.fullmatch(lines[0]): + raise ProxyError("{} не сообщил версию Xray {}".format(path, XRAY_VERSION)) def xray_asset_for_architecture(machine): @@ -620,285 +490,79 @@ def xray_asset_for_architecture(machine): raise ProxyError("архитектура {} не поддерживается установщиком Xray".format(machine)) -def download_file(url, destination, max_bytes): +def download_file(url, destination): try: with urllib.request.urlopen(url, timeout=30) as response: - raw_length = response.headers.get("Content-Length") - if raw_length is None: - declared_length = None - elif re.fullmatch(r"[0-9]+", raw_length) is None: - raise ProxyError("сервер сообщил неверный Content-Length") - else: - declared_length = int(raw_length) - if declared_length > max_bytes: - raise ProxyError("скачиваемый файл превышает допустимый размер") with destination.open("wb") as stream: - actual_length = 0 - while True: - read_size = min( - STREAM_CHUNK_BYTES, max_bytes - actual_length + 1 - ) - chunk = response.read(read_size) - if not chunk: - break - actual_length += len(chunk) - if actual_length > max_bytes: - raise ProxyError("скачиваемый файл превышает допустимый размер") - if declared_length is not None and actual_length > declared_length: - raise ProxyError( - "фактический размер не совпадает с Content-Length" - ) - stream.write(chunk) - if declared_length is not None and actual_length != declared_length: - raise ProxyError("фактический размер не совпадает с Content-Length") - except ProxyError: - raise - except (OSError, urllib.error.URLError, http.client.HTTPException) as error: - raise ProxyError("не удалось скачать официальный Xray: {}".format(error)) + shutil.copyfileobj(response, stream) + except (OSError, http.client.HTTPException) as error: + raise ProxyError("не удалось скачать {}: {}".format(url, error)) -def expected_archive_sha256(digest_path): +def expected_sha256(digest_text): + for line in digest_text.splitlines(): + name, _, value = line.partition("=") + if name.strip() == "SHA2-256": + return value.strip().lower() + raise ProxyError("в файле .dgst нет строки SHA2-256=") + + +def install_xray(destination, downloader): + asset = xray_asset_for_architecture(platform.machine()) + destination.parent.mkdir(parents=True, exist_ok=True) + # Временный каталог рядом с назначением: os.replace тогда атомарен, а + # неудачная установка не трогает старый бинарник. + staging = Path(tempfile.mkdtemp(prefix=".xray-install-", dir=str(destination.parent))) try: - with digest_path.open("rb") as stream: - digest_bytes = stream.read(MAX_DIGEST_BYTES + 1) - except (OSError, UnicodeError) as error: - raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) - if len(digest_bytes) > MAX_DIGEST_BYTES: - raise ProxyError("файл SHA-256 превышает допустимый размер") - try: - digest_text = digest_bytes.decode("ascii") - except UnicodeError as error: - raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error)) - lines = digest_text.split("\n") - if lines and lines[-1] == "": - lines.pop() - expected_lengths = { - "MD5": 32, - "SHA1": 40, - "SHA2-256": 64, - "SHA2-512": 128, - } - records = {} - for line in lines: - match = re.fullmatch( - r"(MD5|SHA1|SHA2-256|SHA2-512)= ([0-9a-f]+)", line - ) - if match is None: - raise ProxyError("файл SHA-256 имеет неожиданный формат") - name, value = match.groups() - if name in records or len(value) != expected_lengths[name]: - raise ProxyError("файл SHA-256 имеет неожиданный формат") - records[name] = value - if set(records) != set(expected_lengths): - raise ProxyError("файл SHA-256 имеет неожиданный формат") - return records["SHA2-256"] - - -def archive_sha256(archive_path): - digest = hashlib.sha256() - total = 0 - try: - with archive_path.open("rb") as stream: - while True: - chunk = stream.read( - min(STREAM_CHUNK_BYTES, MAX_ARCHIVE_BYTES - total + 1) - ) - if not chunk: - break - total += len(chunk) - if total > MAX_ARCHIVE_BYTES: - raise ProxyError("архив Xray превышает допустимый размер") - digest.update(chunk) - except ProxyError: - raise - except OSError as error: - raise ProxyError("не удалось прочитать архив Xray: {}".format(error)) - return digest.hexdigest() - - -def extract_xray_candidate(archive_path, candidate_path): - try: - with zipfile.ZipFile(str(archive_path)) as archive: - members = [item for item in archive.infolist() if item.filename == "xray"] - if len(members) != 1 or members[0].is_dir(): - raise ProxyError("официальный архив не содержит ровно один файл xray") - if members[0].file_size > MAX_XRAY_BYTES: - raise ProxyError("извлекаемый xray превышает допустимый размер") - with archive.open(members[0]) as source: - descriptor = os.open( - str(candidate_path), - os.O_WRONLY | os.O_CREAT | os.O_EXCL, - 0o600, - ) - with os.fdopen(descriptor, "wb") as destination: - extracted = 0 - while True: - chunk = source.read( - min(STREAM_CHUNK_BYTES, MAX_XRAY_BYTES - extracted + 1) - ) - if not chunk: - break - extracted += len(chunk) - if extracted > MAX_XRAY_BYTES: - raise ProxyError( - "извлекаемый xray превышает допустимый размер" - ) - destination.write(chunk) - if extracted != members[0].file_size: - raise ProxyError("размер извлечённого xray не совпадает с ZIP") - candidate_path.chmod(0o755) - except ProxyError: - raise - except (OSError, KeyError, zipfile.BadZipFile) as error: - raise ProxyError("не удалось извлечь xray из проверенного архива: {}".format(error)) - - -def install_destination(): - return Path.home() / ".local/bin/xray" - - -def require_install_parent_within_home(parent): - try: - home = Path.home().resolve(strict=True) - resolved_parent = parent.resolve(strict=False) - resolved_parent.relative_to(home) - except (OSError, RuntimeError, ValueError): - raise ProxyError( - "каталог установки должен находиться внутри домашнего каталога пользователя" - ) - - -def require_install_safe_managed_state(): - path = state_path() - try: - path.lstat() - except FileNotFoundError: - return - except OSError: - raise ProxyError( - "состояние managed-режима не удалось проверить; сначала выполните proxy-client stop" - ) - try: - state = read_managed_state() - outcome = managed_state_outcome(state) - except ProxyError: - raise ProxyError( - "состояние managed-режима не подтверждено; сначала выполните proxy-client stop" - ) - if outcome != "stale": - raise ProxyError( - "managed Xray работает или не подтверждён; сначала выполните proxy-client stop" - ) - - -def acquire_install_lock(): - try: - return acquire_start_lock() - except StartLockBusyError: - raise ProxyError( - "другая операция удерживает блокировку; сначала выполните proxy-client stop" - ) - - -def existing_installation_outcome(destination, replace): - try: - info = destination.lstat() - except FileNotFoundError: - return "install" - except OSError as error: - raise ProxyError("не удалось проверить назначение {}: {}".format(destination, error)) - if stat.S_ISDIR(info.st_mode): - raise ProxyError("назначение {} является каталогом".format(destination)) - if replace: - return "install" - if stat.S_ISREG(info.st_mode) and os.access(str(destination), os.X_OK): + archive = staging / asset + digest = staging / (asset + ".dgst") + downloader(XRAY_RELEASE_BASE_URL + asset, archive) + downloader(XRAY_RELEASE_BASE_URL + asset + ".dgst", digest) + expected = expected_sha256(digest.read_text(encoding="ascii", errors="replace")) + if hashlib.sha256(archive.read_bytes()).hexdigest() != expected: + raise ProxyError("SHA-256 архива {} не совпадает с {}.dgst".format(asset, asset)) try: - xray_version(str(destination)) - except ProxyError: - pass - else: - return "already-installed" - raise ProxyError( - "назначение {} уже существует; для проверенной замены используйте --replace".format( - destination - ) - ) - - -def verify_installed_xray_on_path(destination): - found = shutil.which("xray") - try: - matches = found is not None and Path(found).resolve(strict=True) == destination.resolve( - strict=True - ) - except (OSError, RuntimeError): - matches = False - if not matches: - raise ProxyError( - "Xray установлен в {}, но PATH выбирает другой файл; добавьте ~/.local/bin в начало PATH".format( - destination - ) - ) + with zipfile.ZipFile(str(archive)) as bundle: + candidate = Path(bundle.extract("xray", str(staging))) + except (KeyError, zipfile.BadZipFile) as error: + raise ProxyError("не удалось извлечь xray из {}: {}".format(asset, error)) + candidate.chmod(0o755) + check_xray_version(str(candidate)) + os.replace(str(candidate), str(destination)) + finally: + shutil.rmtree(str(staging), ignore_errors=True) def command_install(replace=False, downloader=download_file): - destination = install_destination() - lock = acquire_install_lock() - try: - require_install_safe_managed_state() - require_install_parent_within_home(destination.parent) - outcome = existing_installation_outcome(destination, replace) - if outcome == "already-installed": - print("Xray {} уже установлен: {}".format(XRAY_VERSION, destination)) - else: - asset = xray_asset_for_architecture(platform.machine()) - try: - destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755) - except OSError as error: - raise ProxyError( - "не удалось создать каталог установки {}: {}".format( - destination.parent, error - ) + destination = Path.home() / ".local/bin/xray" + if destination.is_dir(): + raise ProxyError("назначение {} является каталогом".format(destination)) + if os.path.lexists(str(destination)) and not replace: + try: + check_xray_version(str(destination)) + except ProxyError: + raise ProxyError( + "{} уже существует и это не Xray {}; заменить: proxy-client install --replace".format( + destination, XRAY_VERSION ) - require_install_parent_within_home(destination.parent) - if not destination.parent.is_dir(): - raise ProxyError( - "каталог установки {} не является каталогом".format(destination.parent) - ) - staging = Path( - tempfile.mkdtemp(prefix=".xray-install-", dir=str(destination.parent)) ) - try: - staging.chmod(0o700) - archive_path = staging / asset - digest_path = staging / (asset + ".dgst") - candidate_path = staging / "xray" - downloader( - XRAY_RELEASE_BASE_URL + asset, - archive_path, - MAX_ARCHIVE_BYTES, - ) - downloader( - XRAY_RELEASE_BASE_URL + asset + ".dgst", - digest_path, - MAX_DIGEST_BYTES, - ) - expected = expected_archive_sha256(digest_path) - if archive_sha256(archive_path) != expected: - raise ProxyError("SHA-256 архива Xray не совпадает") - extract_xray_candidate(archive_path, candidate_path) - xray_version(str(candidate_path)) - os.replace(str(candidate_path), str(destination)) - finally: - shutil.rmtree(str(staging), ignore_errors=True) - print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) - finally: - lock.close() - verify_installed_xray_on_path(destination) + print("Xray {} уже установлен: {}".format(XRAY_VERSION, destination)) + else: + install_xray(destination, downloader) + print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) + if managed_xray_running(): + print("Работающий Xray остался прежним; перезапустите proxy-client start") + found = shutil.which("xray") + if found is None or Path(found).resolve() != destination.resolve(): + raise ProxyError( + "Xray установлен в {}, но PATH выбирает другой xray или не находит его; " + 'выполните: export PATH="$HOME/.local/bin:$PATH"'.format(destination) + ) return 0 def run_xray_test(path, config_path): + # Вывод Xray глушим: он может процитировать секреты из конфига. try: completed = subprocess.run( [path, "run", "-test", "-c", str(config_path)], @@ -908,204 +572,45 @@ def run_xray_test(path, config_path): check=False, ) except (OSError, subprocess.TimeoutExpired): - raise ProxyError("проверка созданной конфигурации Xray не выполнена") - if completed.returncode != 0: + completed = None + if completed is None or completed.returncode != 0: raise ProxyError("созданная конфигурация не прошла xray run -test") -def process_alive(pid): - try: - Path("/proc/{}".format(pid)).stat() - return True - except FileNotFoundError: - return False - except OSError: - return True - - -def parse_proc_stat_starttime(text): - comm_end = text.rfind(")") - if comm_end < 0: - raise ValueError("missing comm terminator") - fields = text[comm_end + 1 :].split() - if len(fields) <= 19: - raise ValueError("missing starttime") - return int(fields[19]) - - -def process_starttime(pid): - try: - text = Path("/proc/{}/stat".format(pid)).read_text(encoding="ascii") - return parse_proc_stat_starttime(text) - except (OSError, UnicodeError, ValueError): - return None - - -def process_executable(pid): - try: - return str(Path("/proc/{}/exe".format(pid)).resolve(strict=True)) - except (OSError, RuntimeError): - return None - - -def read_boot_id(): - try: - value = Path("/proc/sys/kernel/random/boot_id").read_text( - encoding="ascii" - ).strip() - except (OSError, UnicodeError): - return None - return value or None - - -def require_boot_id(): - value = read_boot_id() - if value is None: - raise ProxyError("не удалось определить идентификатор текущей загрузки") - return value - - -def process_cmdline(pid): - try: - raw = Path("/proc/{}/cmdline".format(pid)).read_bytes() - except OSError: - return [] - return [item.decode("utf-8", "surrogateescape") for item in raw.split(b"\0") if item] - - -def process_argument_resolves_to(pid, arguments, expected_path): - try: - process_cwd = Path("/proc/{}/cwd".format(pid)).resolve(strict=True) - except (OSError, RuntimeError): - return False - for argument in arguments: - if not argument or "\0" in argument: - continue - candidate = Path(argument) - if not candidate.is_absolute(): - candidate = process_cwd / candidate - try: - if candidate.resolve(strict=True) == expected_path: - return True - except (OSError, RuntimeError): - continue - return False - - -def read_managed_state(): +def read_state(): path = state_path() try: - info = path.lstat() - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): - raise ProxyError("состояние управляемого Xray не подтверждено") - if info.st_uid != os.getuid() or info.st_mode & 0o077: - raise ProxyError("состояние управляемого Xray не подтверждено") - value = json.loads(path.read_text(encoding="utf-8")) + state = json.loads(path.read_text(encoding="utf-8")) + return {key: state[key] for key in ("supervisor_pid", "xray_pid", "xray_path")} except FileNotFoundError: raise ProxyError("управляемый Xray не запущен; выполните proxy-client start") - except ProxyError: - raise - except (OSError, ValueError, TypeError): - raise ProxyError("состояние управляемого Xray не подтверждено") - if not isinstance(value, dict) or set(value) != STATE_KEYS: - raise ProxyError("состояние управляемого Xray не подтверждено") - integer_keys = ( - "supervisor_pid", - "xray_pid", - "port", - "supervisor_starttime", - "xray_starttime", - ) - if not all( - isinstance(value[key], int) and not isinstance(value[key], bool) - for key in integer_keys - ): - raise ProxyError("состояние управляемого Xray не подтверждено") - string_keys = ( - "xray_path", - "config_path", - "address", - "supervisor_path", - "boot_id", - ) - if not all(isinstance(value[key], str) for key in string_keys): - raise ProxyError("состояние управляемого Xray не подтверждено") - return value + except (ValueError, TypeError, KeyError): + raise ProxyError( + "файл состояния {} повреждён; если Xray не запущен, выполните: rm {}".format( + path, shlex.quote(str(path)) + ) + ) -def supervisor_identity_matches(state): - pid = state["supervisor_pid"] - if pid <= 1: - return False - arguments = process_cmdline(pid) - if len(arguments) < 2: - return False - if not process_argument_resolves_to( - pid, [arguments[1]], Path(state["supervisor_path"]) - ): - return False - return process_starttime(pid) == state["supervisor_starttime"] - - -def xray_identity_matches(state): - pid = state["xray_pid"] - if pid <= 1 or process_executable(pid) != state["xray_path"]: - return False - return process_starttime(pid) == state["xray_starttime"] - - -def wait_for_child_starttime(child): - while True: - value = process_starttime(child.pid) - if value is not None: - return value - if child.poll() is not None: - raise ProxyError("Xray завершился до сохранения идентичности процесса") - time.sleep(0.01) - - -def signal_supervisor(state, signal_number): - if read_boot_id() != state["boot_id"] or not supervisor_identity_matches(state): - return False +def xray_running(state): + # Процесс с этим PID должен исполнять тот самый xray. После + # install --replace ядро дописывает к пути старого файла « (deleted)». try: - os.kill(state["supervisor_pid"], signal_number) + executable = os.readlink("/proc/{}/exe".format(state["xray_pid"])) except OSError: return False - return True + return executable in (state["xray_path"], state["xray_path"] + " (deleted)") -def signal_xray(state, signal_number): - if read_boot_id() != state["boot_id"] or not xray_identity_matches(state): - return False +def supervisor_running(state): try: - os.kill(state["xray_pid"], signal_number) + cmdline = Path("/proc/{}/cmdline".format(state["supervisor_pid"])).read_bytes() except OSError: return False - return True + return b"proxy-client" in cmdline -def managed_state_outcome(state): - current_boot_id = read_boot_id() - if current_boot_id is None: - return "unverifiable" - if current_boot_id != state["boot_id"]: - return "stale" - if state["supervisor_pid"] == state["xray_pid"]: - return "unverifiable" - supervisor_alive = process_alive(state["supervisor_pid"]) - xray_alive = process_alive(state["xray_pid"]) - if not supervisor_alive and not xray_alive: - return "stale" - supervisor_matches = supervisor_identity_matches(state) - xray_matches = xray_identity_matches(state) - if supervisor_matches and xray_matches: - return "healthy" - if not supervisor_alive and xray_matches: - return "orphan" - return "unverifiable" - - -def cleanup_stale_files(): +def remove_state_files(): for path in (state_path(), generated_config_path()): try: path.unlink() @@ -1113,204 +618,45 @@ def cleanup_stale_files(): pass -def unverifiable_state_error(): - return ProxyError( - "личность процесса не подтверждена; сигналы не отправлены, " - "удалите state.json и client.json вручную после проверки процессов" - ) - - -def verify_managed_state(config=None, require_port=True): - state = read_managed_state() - outcome = managed_state_outcome(state) - if outcome == "stale": - lock = acquire_start_lock() - try: - state = read_managed_state() - outcome = managed_state_outcome(state) - if outcome == "stale": - cleanup_stale_files() - raise ProxyError("устаревшее состояние управляемого Xray очищено") - finally: - lock.close() - if outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; выполните proxy-client stop") - if outcome != "healthy": - raise unverifiable_state_error() - if config is not None and ( - config.proxy_type != "xray" - or config.address != state["address"] - or config.port != state["port"] - ): - raise ProxyError("настройки и состояние управляемого Xray не совпадают") - if require_port: - check_endpoint(state["address"], state["port"]) +def managed_state(): + """Состояние работающего Xray; устаревшие служебные файлы удаляет.""" + state = read_state() + if not xray_running(state): + remove_state_files() + raise ProxyError("управляемый Xray не запущен; устаревшие служебные файлы удалены") return state -def acquire_start_lock(): - private_directory(state_home()) +def managed_xray_running(): try: - descriptor = os.open(str(lock_path()), os.O_RDWR | os.O_CREAT, 0o600) - lock = os.fdopen(descriptor, "r+") - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - os.chmod(str(lock_path()), 0o600) - except BlockingIOError: - if "lock" in locals(): - lock.close() - raise StartLockBusyError("другой управляемый запуск уже удерживает блокировку") - except OSError as error: - if "lock" in locals(): - lock.close() - elif "descriptor" in locals(): - os.close(descriptor) - raise ProxyError("не удалось открыть файл блокировки: {}".format(error)) - return lock - - -def safe_summary(profile, local_port): - query = profile.query - print( - "Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format( - profile.server, - profile.port, - "RAW" if profile.transport == "tcp" else "XHTTP", - profile.security.upper(), - query["sni"], - query["fp"], - local_port, - ), - flush=True, - ) - if profile.transport == "tcp": - if "host" in query or "alpn" in query: - print("TCP/REALITY: Host и ALPN из ссылки не используются", flush=True) - print("Секреты REALITY: скрыты", flush=True) - else: - print( - "XHTTP: Host {}, длина пути: {}, ALPN {}".format( - query["host"], len(query["path"]), query.get("alpn", "не задан") - ), - flush=True, - ) - if profile.query["encryption"] != "none": - print("VLESS-шифрование: mlkem768x25519plus", flush=True) - print("UUID и ключи: скрыты", flush=True) - - -def warn_legacy(profile): - if profile.legacy_insecure: - sys.stderr.write( - "Предупреждение: legacy insecure не переносится; сертификат проверяется обычным способом.\n" - ) - - -def diagnose_ip(config): - connection = http.client.HTTPConnection( - config.address, config.port, timeout=preflight_timeout() - ) - try: - connection.request( - "GET", - "http://ifconfig.me/ip", - headers={"Host": "ifconfig.me", "User-Agent": "proxy-client/1"}, - ) - response = connection.getresponse() - address = response.read(256).decode("ascii", "replace").strip() - if response.status != 200 or not address: - raise ValueError("empty response") - parsed_address = ipaddress.ip_address(address) - print("Внешний IP через прокси: {}".format(parsed_address), flush=True) - except (OSError, ValueError, http.client.HTTPException): - sys.stderr.write( - "Предупреждение: внешний IP через прокси определить не удалось; Xray продолжает работу.\n" - ) - finally: - connection.close() - - -def cleanup_managed_files(supervisor_pid): - try: - current = read_managed_state() - if current["supervisor_pid"] == supervisor_pid: - state_path().unlink() + return xray_running(read_state()) except ProxyError: - pass - try: - generated_config_path().unlink() - except FileNotFoundError: - pass + return False -def command_status(config): - if config.proxy_type == "external": - check_reachable(config, preflight_timeout()) - print( - "{} (external) доступен по адресу {}:{}".format( - config.name, config.address, config.port - ) +def require_supervised_xray(): + if not supervisor_running(managed_state()): + raise ProxyError( + "Xray работает без proxy-client start (его процесс завершён); " + "выполните proxy-client stop" ) - else: - verify_managed_state(config) - print( - "{}: управляемый Xray работает на {}:{}".format( - config.name, config.address, config.port - ) - ) - return 0 -def command_env(config): - if config.proxy_type == "external": - check_reachable(config, preflight_timeout()) - else: - verify_managed_state(config) - lines = ["{}={}".format(key, value) for key, value in environment_values(config)] - sys.stdout.write("\n".join(lines) + "\n") - return 0 +def wait_until(condition, timeout): + deadline = time.monotonic() + timeout + while not condition(): + if time.monotonic() > deadline: + return False + time.sleep(0.05) + return True -def command_run(config, remainder): - if not remainder or remainder[0] != "--" or len(remainder) == 1: - raise ProxyError("использование: proxy-client run -- COMMAND ARG...") - command = remainder[1:] - if shutil.which(command[0]) is None: - sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) - return 127 - - if config.proxy_type == "external": - check_reachable(config, preflight_timeout()) - else: - verify_managed_state(config) - child_environment = os.environ.copy() - child_environment.update(dict(environment_values(config))) +def port_open(address, port): try: - completed = subprocess.run(command, env=child_environment, check=False) - except FileNotFoundError: - sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) - return 127 - except OSError as error: - sys.stderr.write("Ошибка: не удалось запустить {}: {}\n".format(command[0], error)) - return 126 - if completed.returncode < 0: - return 128 - completed.returncode - return completed.returncode - - -def command_start(config, profile_file): - if config.proxy_type == "external": - raise ProxyError("start доступен только для type=xray; измените машинный конфиг") - try: - address = ipaddress.ip_address(config.address) - except ValueError: - raise ProxyError("для type=xray поле address должно быть loopback IP-адресом") - if not address.is_loopback: - raise ProxyError("для type=xray требуется loopback IP-адрес") - lock = acquire_start_lock() - try: - return command_start_locked(config, profile_file, address) - finally: - lock.close() + socket.create_connection((address, port), timeout=0.2).close() + except OSError: + return False + return True def check_port_available(address, port): @@ -1326,195 +672,205 @@ def check_port_available(address, port): raise ProxyError("локальный порт {} занят или недоступен".format(port)) -def command_start_locked(config, profile_file, address): - if state_path().exists() or state_path().is_symlink(): - try: - existing_state = read_managed_state() - except ProxyError: - raise unverifiable_state_error() - outcome = managed_state_outcome(existing_state) - if outcome == "stale": - cleanup_stale_files() - elif outcome == "orphan": - raise ProxyError("обнаружен Xray-сирота; сначала выполните proxy-client stop") - elif outcome == "healthy": - raise ProxyError("управляемый Xray уже запущен") - else: - raise unverifiable_state_error() +def acquire_start_lock(): + private_directory(state_home()) + lock = (state_home() / "lock").open("a") + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + lock.close() + raise ProxyError("управляемый Xray уже запущен другим proxy-client start") + return lock - check_port_available(address, config.port) - profile_path = ( - Path(profile_file) - if profile_file - else proxy_client_config_home() / "default.url" +def print_summary(profile, local_port): + query = profile.query + print( + "Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format( + profile.server, + profile.port, + "RAW" if profile.transport == "tcp" else "XHTTP", + profile.security.upper(), + query["sni"], + query["fp"], + local_port, + ) ) - raw_profile = read_profile(profile_path) + if profile.transport == "tcp": + if "host" in query or "alpn" in query: + print("TCP/REALITY: Host и ALPN из ссылки не используются") + print("Секреты REALITY: скрыты") + else: + print( + "XHTTP: Host {}, длина пути: {}, ALPN {}".format( + query["host"], len(query["path"]), query.get("alpn", "не задан") + ) + ) + if query["encryption"] != "none": + print("VLESS-шифрование: mlkem768x25519plus") + print("UUID и ключи: скрыты", flush=True) + if profile.insecure: + sys.stderr.write( + "Предупреждение: allowInsecure/insecure не переносятся; " + "сертификат проверяется обычным способом.\n" + ) + + +def diagnose_ip(config): + connection = http.client.HTTPConnection( + config.address, config.port, timeout=preflight_timeout() + ) + try: + connection.request( + "GET", + "http://ifconfig.me/ip", + headers={"Host": "ifconfig.me", "User-Agent": "proxy-client/1"}, + ) + response = connection.getresponse() + address = response.read(256).decode("ascii", "replace").strip() + if response.status != 200: + raise ValueError("unexpected status") + print("Внешний IP через прокси: {}".format(ipaddress.ip_address(address)), flush=True) + except (OSError, ValueError, http.client.HTTPException): + sys.stderr.write( + "Предупреждение: внешний IP через прокси определить не удалось; Xray продолжает работу.\n" + ) + finally: + connection.close() + + +def exit_code(returncode): + # Как в shell: убитый сигналом N процесс даёт 128+N. + return 128 - returncode if returncode < 0 else returncode + + +def interrupt(_signum, _frame): + raise KeyboardInterrupt + + +def command_status(config): + check_proxy(config) + if config.proxy_type == "external": + print("{} (external) доступен по адресу {}:{}".format(config.name, config.address, config.port)) + else: + print("{}: управляемый Xray работает на {}:{}".format(config.name, config.address, config.port)) + return 0 + + +def command_env(config): + check_proxy(config) + lines = ["{}={}".format(key, value) for key, value in environment_values(config)] + sys.stdout.write("\n".join(lines) + "\n") + return 0 + + +def command_run(config, remainder): + if not remainder or remainder[0] != "--" or len(remainder) == 1: + raise ProxyError("использование: proxy-client run -- COMMAND ARG...") + command = remainder[1:] + if shutil.which(command[0]) is None: + sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) + return 127 + check_proxy(config) + child_environment = os.environ.copy() + child_environment.update(dict(environment_values(config))) + try: + completed = subprocess.run(command, env=child_environment, check=False) + except OSError as error: + sys.stderr.write("Ошибка: не удалось запустить {}: {}\n".format(command[0], error)) + return 126 + return exit_code(completed.returncode) + + +def command_start(config, profile_file): + if config.proxy_type != "xray": + raise ProxyError( + "start доступен только для type=xray; измените {}".format(config_home() / "config") + ) + try: + loopback = ipaddress.ip_address(config.address).is_loopback + except ValueError: + loopback = False + if not loopback: + raise ProxyError("для type=xray поле address должно быть loopback-адресом, например 127.0.0.1") + lock = acquire_start_lock() + try: + return run_managed_xray(config, profile_file) + finally: + lock.close() + + +def run_managed_xray(config, profile_file): + if state_path().exists(): + if xray_running(read_state()): + raise ProxyError( + "Xray остался от прошлого proxy-client start; сначала выполните proxy-client stop" + ) + remove_state_files() + check_port_available(ipaddress.ip_address(config.address), config.port) + profile_path = Path(profile_file) if profile_file else config_home() / "default.url" + profile = parse_vless(read_profile(profile_path)) executable = resolve_xray() - version = xray_version(executable) - profile = parse_vless(raw_profile) - generated = build_xray_config(profile, config.address, config.port) - write_private_json(generated_config_path(), generated) + check_xray_version(executable) + write_private_json( + generated_config_path(), build_xray_config(profile, config.address, config.port) + ) + child = None try: run_xray_test(executable, generated_config_path()) - print("Xray: {} ({})".format(executable, version), flush=True) - safe_summary(profile, config.port) - warn_legacy(profile) - stopping = {"signal": None} - - def request_stop(signal_number, _frame): - if stopping["signal"] is None: - stopping["signal"] = signal_number - - previous = {} - for handled in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - previous[handled] = signal.signal(handled, request_stop) - boot_id = require_boot_id() - supervisor_path = str(Path(__file__).resolve(strict=True)) - supervisor_starttime = process_starttime(os.getpid()) - if supervisor_starttime is None: - raise ProxyError("не удалось сохранить идентичность супервизора") - child = subprocess.Popen( - [executable, "run", "-c", str(generated_config_path())] + print("Xray: {} ({})".format(executable, XRAY_VERSION)) + print_summary(profile, config.port) + # SIGTERM (от proxy-client stop) и SIGHUP обрабатываем как Ctrl+C. + signal.signal(signal.SIGTERM, interrupt) + signal.signal(signal.SIGHUP, interrupt) + child = subprocess.Popen([executable, "run", "-c", str(generated_config_path())]) + write_private_json( + state_path(), + {"supervisor_pid": os.getpid(), "xray_pid": child.pid, "xray_path": executable}, ) - xray_starttime = wait_for_child_starttime(child) - state = { - "supervisor_pid": os.getpid(), - "xray_pid": child.pid, - "xray_path": executable, - "config_path": str(generated_config_path()), - "address": config.address, - "port": config.port, - "supervisor_path": supervisor_path, - "supervisor_starttime": supervisor_starttime, - "xray_starttime": xray_starttime, - "boot_id": boot_id, - } - write_private_json(state_path(), state) - deadline = time.monotonic() + max(1.0, preflight_timeout()) - last_error = None - while time.monotonic() < deadline: - if child.poll() is not None: - raise ProxyError("Xray завершился до готовности локального порта") - try: - check_endpoint(config.address, config.port) - last_error = None - break - except ProxyError as error: - last_error = error - time.sleep(0.03) - if last_error is not None: - raise ProxyError( - "локальный порт {} занят или недоступен".format(config.port) - ) + if not wait_until( + lambda: port_open(config.address, config.port), max(1.0, preflight_timeout()) + ): + raise ProxyError("Xray не открыл локальный порт {}".format(config.port)) diagnose_ip(config) - while child.poll() is None and stopping["signal"] is None: - time.sleep(0.05) - if stopping["signal"] is not None and child.poll() is None: - if not signal_xray(state, stopping["signal"]): - if child.poll() is None: - raise unverifiable_state_error() - else: - try: - child.wait(timeout=stop_timeout()) - except subprocess.TimeoutExpired: - if not signal_xray(state, signal.SIGKILL): - if child.poll() is None: - raise unverifiable_state_error() - else: - child.wait() - return_code = child.wait() - if stopping["signal"] is not None: - return 0 - if return_code < 0: - return 128 - return_code - return return_code + print( + "Управляемый Xray работает на {}:{}; остановка: Ctrl+C или proxy-client stop".format( + config.address, config.port + ), + flush=True, + ) + return exit_code(child.wait()) + except KeyboardInterrupt: + return 0 finally: - if "previous" in locals(): - for handled, handler in previous.items(): - signal.signal(handled, handler) - preserve_managed_files = False - if "child" in locals() and child.poll() is None and "state" in locals(): - if signal_xray(state, signal.SIGTERM): - try: - child.wait(timeout=stop_timeout()) - except subprocess.TimeoutExpired: - if signal_xray(state, signal.SIGKILL): - child.wait() - elif child.poll() is None: - preserve_managed_files = True - elif child.poll() is None: - preserve_managed_files = True - elif "child" in locals() and child.poll() is None: - preserve_managed_files = True - if preserve_managed_files: - raise unverifiable_state_error() - cleanup_managed_files(os.getpid()) - - -def stop_timeout(): - raw = os.environ.get("PROXY_CLIENT_STOP_TIMEOUT", "5") - try: - value = float(raw) - if not value > 0: - raise ValueError - except ValueError: - raise ProxyError("PROXY_CLIENT_STOP_TIMEOUT должен быть положительным числом секунд") - return value - - -def stop_managed_state(timeout, state, outcome): - if outcome == "stale": - cleanup_stale_files() - raise ProxyError("устаревшее состояние управляемого Xray очищено") - if outcome == "unverifiable": - raise unverifiable_state_error() - if outcome == "orphan": - if not signal_xray(state, signal.SIGTERM): - raise ProxyError("личность Xray-сироты изменилась; сигнал не отправлен") - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not process_alive(state["xray_pid"]): - cleanup_stale_files() - print("Xray-сирота остановлен, служебные файлы удалены") - return 0 - time.sleep(0.05) - if not signal_xray(state, signal.SIGKILL): - raise ProxyError("личность Xray-сироты изменилась; SIGKILL не отправлен") - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not process_alive(state["xray_pid"]): - cleanup_stale_files() - print("Xray-сирота остановлен, служебные файлы удалены") - return 0 - time.sleep(0.05) - raise ProxyError("остановка Xray-сироты после SIGKILL превысила таймаут") - - check_endpoint(state["address"], state["port"]) - if not signal_supervisor(state, signal.SIGTERM): - raise ProxyError("не удалось послать сигнал подтверждённому супервизору") - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if not process_alive(state["xray_pid"]) and not state_path().exists(): - print("Управляемый Xray остановлен") - return 0 - time.sleep(0.05) - raise ProxyError("штатная остановка управляемого Xray превысила таймаут") + if child is not None and child.poll() is None: + child.terminate() + try: + child.wait(STOP_TIMEOUT) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + remove_state_files() def command_stop(): - timeout = stop_timeout() - state = read_managed_state() - outcome = managed_state_outcome(state) - if outcome in ("stale", "orphan"): - lock = acquire_start_lock() - try: - state = read_managed_state() - outcome = managed_state_outcome(state) - return stop_managed_state(timeout, state, outcome) - finally: - lock.close() - return stop_managed_state(timeout, state, outcome) + state = managed_state() + # Живой супервизор сам остановит Xray; иначе Xray гасим напрямую. + running_supervisor = supervisor_running(state) + target = state["supervisor_pid"] if running_supervisor else state["xray_pid"] + os.kill(target, signal.SIGTERM) + + def stopped(): + return not xray_running(state) and not supervisor_running(state) + + if not wait_until(stopped, STOP_TIMEOUT + 1): + if xray_running(state): + os.kill(state["xray_pid"], signal.SIGKILL) + if not wait_until(stopped, STOP_TIMEOUT): + raise ProxyError("управляемый Xray не остановился за {} с".format(2 * STOP_TIMEOUT + 1)) + remove_state_files() + print("Управляемый Xray остановлен") + return 0 def build_parser(): @@ -1525,7 +881,7 @@ def build_parser(): subparsers = parser.add_subparsers(dest="subcommand") install_parser = subparsers.add_parser("install", help="установить проверенный Xray") install_parser.add_argument( - "--replace", action="store_true", help="проверенно заменить существующее назначение" + "--replace", action="store_true", help="заменить существующий ~/.local/bin/xray" ) subparsers.add_parser("status", help="проверить доступность активного прокси") subparsers.add_parser("env", help="вывести восемь proxy-переменных") @@ -1559,7 +915,7 @@ def main(): return command_run(config, arguments.arguments) if arguments.subcommand == "start": return command_start(config, arguments.profile_file) - except ProxyError as error: + except (ProxyError, OSError) as error: sys.stderr.write("Ошибка: {}\n".format(error)) return 1 return 2 diff --git a/proxy-client/README.md b/proxy-client/README.md index 0d2617f..844a20d 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -19,13 +19,16 @@ CLI требует Python 3.8+ и использует только станда ~/src/proxy-client/install.sh ``` -При наличии Stow установщик использует его, иначе создаёт те же три симлинка -напрямую: CLI, Bash-модуль и каталог шаблонов. Исходный каталог после установки -нельзя перемещать: домашние ссылки зависят от его постоянного пути. +Установщик создаёт три ссылки на исходный каталог: CLI, Bash-модуль и каталог +шаблонов. Поэтому исходный каталог после установки нельзя перемещать. Если +какой-то из путей уже занят чужим файлом или ссылкой, установщик ничего не +меняет и перечисляет все такие пути. Повторный запуск безопасен. -По умолчанию установщик добавляет в `~/.bashrc` помеченный блок для -`~/.local/bin` и Bash-модуля. После установки откройте новый Bash или примените -блок в текущей сессии: +По умолчанию установщик дописывает в `~/.bashrc` блок между строками +`# >>> proxy-client >>>` и `# <<< proxy-client <<<`: он добавляет +`~/.local/bin` в `PATH` и подключает Bash-модуль. Если блок уже есть, файл не +меняется. После установки откройте новый Bash или примените блок в текущей +сессии: ```bash source ~/.bashrc @@ -73,6 +76,12 @@ source ~/.bash_custom Если `~/.local/bin` отсутствует в `PATH` и в новых сессиях, добавьте ту же строку `export` в свой Bash-конфиг: корневой установщик dotfiles его не изменяет. +Проверить пакет после правок (из корня dotfiles): + +```bash +tests/test_proxy_client_full.sh +``` + ## Как выбирается прокси Режим определяется только машинным файлом @@ -253,8 +262,13 @@ proxy-client stop плане. Systemd, фоновый автозапуск и скрытый запуск из `run` не используются. Один loopback-порт принимает HTTP CONNECT и SOCKS5. -`stop` завершает управляемые процессы и удаляет созданные служебные файлы. Он -работает и после изменения машинного типа на `external`. +Остановить Xray можно двумя способами: `Ctrl+C` в окне `start` или +`proxy-client stop` из любого терминала. Оба гасят Xray и удаляют служебные +файлы. Если процесс `start` был убит (например, закрыли окно tmux), Xray может +остаться работать: `status` и `env` тогда сообщат об ошибке, а `stop` добьёт его +и уберёт файлы. `stop` работает и после изменения машинного типа на `external`. + +Второй `start`, пока первый работает, завершится ошибкой. ## Диагностика @@ -343,7 +357,8 @@ source ~/.config/proxy-client/shell.bash Поддерживаются VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные, повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN -`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. +`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом, его содержимое +проверяет `xray run -test`. Кроме `encryption=none`, принимается VLESS-шифрование `encryption=mlkem768x25519plus.…` из новых ссылок Hiddify; остальную часть @@ -351,9 +366,9 @@ source ~/.config/proxy-client/shell.bash `flow=xtls-rprx-vision`. Косая черта перед параметрами (`…:443/?type=…`) допустима, другой путь в ссылке — нет. -Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и -`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но -обычная проверка сертификата остаётся включённой. +Xray 26.3.27 использует `pcs` и `vcn`. Устаревшие `allowInsecure` и `insecure` +принимаются с любым значением, но не переносятся: при значении `1` или `true` +выводится предупреждение, а сертификат проверяется обычным способом. Последняя версия Xray, где `allowInsecure` ещё применяется, — [25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция @@ -366,14 +381,18 @@ Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allow `x86_64`/`amd64` используется `Xray-linux-64.zip`, для `aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`; остальные архитектуры отклоняются. -CLI скачивает официальный архив и файл `.dgst`, проверяет SHA-256, ограничения -размера и фактическую версию бинарника, а затем атомарно заменяет назначение. -Корректная уже установленная версия даёт no-op без сети. Другой файл или симлинк -не заменяется без `--replace`; каталог не заменяется никогда. +CLI скачивает официальный архив и файл `.dgst`, сверяет SHA-256 и фактическую +версию бинарника, а затем атомарно заменяет назначение. При любой ошибке старый +файл остаётся на месте. Если нужная версия уже установлена, сеть не нужна и +ничего не меняется. Другой файл или симлинк не заменяется без `--replace` +(симлинк заменяется сам, файл по ссылке не трогается); каталог не заменяется +никогда. Если после установки `PATH` находит другой `xray`, `install` сообщает об +ошибке и подсказывает команду. + +Устанавливать можно и при работающем Xray: он продолжит работу со старым файлом, +а `install` напомнит перезапустить `start`. `start` использует первый `xray` из `PATH` и принимает только версию 26.3.27. -Если `PATH` выбирает другой бинарник, команда сообщает об ошибке. Во время -живого или неподтверждённого управляемого процесса установка запрещена. ### Файлы и безопасность @@ -385,6 +404,8 @@ CLI скачивает официальный архив и файл `.dgst`, п | `$XDG_STATE_HOME/proxy-client/` или `~/.local/state/proxy-client/` | Состояние и сгенерированный JSON | Сводка скрывает UUID и ключи, а для XHTTP показывает только длину `path`. -Служебный каталог закрыт для других пользователей и очищается после штатной -остановки Xray. Сбой определения внешнего IP показывается как предупреждение и +Если файл профиля доступен другим пользователям, `start` не запустится и +покажет готовую команду `chmod 600` для конечного файла (у симлинка — для файла, +на который он указывает). Служебный каталог закрыт для других пользователей +(`700`, файлы в нём — `600`) и очищается после остановки Xray. Сбой определения внешнего IP показывается как предупреждение и не останавливает уже готовый процесс. diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index d9e1503..d84d8e9 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -1,8 +1,7 @@ #!/usr/bin/env python3 -import fcntl -import http.client -import json +"""proxy-client без управляемого Xray: команда, машинный конфиг, разбор ссылки, install.""" import hashlib +import json import os import runpy import socket @@ -10,308 +9,92 @@ import subprocess import sys import tempfile import unittest -import urllib.error import zipfile -from unittest import mock from pathlib import Path +from unittest import mock +from urllib.parse import quote REPO_ROOT = Path(__file__).resolve().parents[1] CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" +MODULE = runpy.run_path(str(CLI)) +ProxyError = MODULE["ProxyError"] -class FakeUrlResponse: - def __init__(self, data=b"", content_length=None, read_error=None): - self.data = data - self.position = 0 - self.read_error = read_error - self.headers = {} - if content_length is not None: - self.headers["Content-Length"] = str(content_length) - - def __enter__(self): - return self - - def __exit__(self, _type, _value, _traceback): - return False - - def read(self, size=-1): - if self.read_error is not None: - raise self.read_error - if size < 0: - size = len(self.data) - self.position - chunk = self.data[self.position : self.position + size] - self.position += len(chunk) - return chunk +def closed_port(): + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] -def checking_socket_setup(outcome="success"): - if outcome == "success": - outcome_line = " return Connection()\n" - elif outcome == "refused": - outcome_line = " raise OSError('connection refused')\n" - else: - raise ValueError("unknown socket outcome: {}".format(outcome)) - return ( - "import json, os, socket\n" - "expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n" - "class Connection:\n" - " def close(self):\n" - " pass\n" - "def checked_create_connection(endpoint, timeout):\n" - " actual_connection = [endpoint[0], endpoint[1], timeout]\n" - " if actual_connection != expected_connection:\n" - " raise AssertionError('socket call mismatch: expected %r, got %r' " - "% (expected_connection, actual_connection))\n" - + outcome_line - + "socket.create_connection = checked_create_connection" - ) - - -SUCCESSFUL_SOCKET_SETUP = checking_socket_setup() -REFUSED_SOCKET_SETUP = checking_socket_setup("refused") - - -class ProxyClientCliTest(unittest.TestCase): +class ExternalProxyCliTest(unittest.TestCase): def setUp(self): - self.temp_dir = tempfile.TemporaryDirectory() - self.config_home = Path(self.temp_dir.name) - self.environment = os.environ.copy() - self.environment["XDG_CONFIG_HOME"] = str(self.config_home) - self.environment["PYTHONDONTWRITEBYTECODE"] = "1" + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.config_dir = self.root / "proxy-client" + self.environment = dict( + os.environ, + XDG_CONFIG_HOME=str(self.root), + XDG_STATE_HOME=str(self.root / "state"), + PYTHONDONTWRITEBYTECODE="1", + ) + self.listener = socket.socket() + self.addCleanup(self.listener.close) + self.listener.bind(("127.0.0.1", 0)) + self.listener.listen(16) + self.port = self.listener.getsockname()[1] - def tearDown(self): - self.temp_dir.cleanup() - - def write_config( - self, - port, - proxy_type="external", - name="Test proxy", - address="127.0.0.1", - no_proxy_extra=".example.test", - ): - config_dir = self.config_home / "proxy-client" - config_dir.mkdir(exist_ok=True) - (config_dir / "config").write_text( - "[proxy]\n" - "type = {}\n" - "name = {}\n" - "address = {}\n" - "port = {}\n" + def write_config(self, port=None, proxy_type="external", address="127.0.0.1", + no_proxy_extra=".example.test"): + self.config_dir.mkdir(exist_ok=True) + (self.config_dir / "config").write_text( + "[proxy]\ntype = {}\nname = Test proxy\naddress = {}\nport = {}\n" "no_proxy_extra = {}\n".format( - proxy_type, name, address, port, no_proxy_extra + proxy_type, address, port or self.port, no_proxy_extra ), encoding="utf-8", ) - def write_raw_config(self, content): - config_dir = self.config_home / "proxy-client" - config_dir.mkdir(exist_ok=True) - path = config_dir / "config" - if isinstance(content, bytes): - path.write_bytes(content) - else: - path.write_text(content, encoding="utf-8") - - def run_cli(self, *arguments, environment=None): - command_environment = self.environment.copy() - if environment: - command_environment.update(environment) + def run_cli(self, *arguments, **environment): return subprocess.run( [str(CLI)] + list(arguments), + env=dict(self.environment, **environment), text=True, capture_output=True, - env=command_environment, + timeout=15, check=False, ) - def run_cli_with_socket_setup( - self, arguments, setup, environment=None, expected_connection=None - ): - command_environment = self.environment.copy() - if environment: - command_environment.update(environment) - command_environment["TEST_CLI_ARGUMENTS"] = json.dumps(list(arguments)) - if expected_connection is not None: - command_environment["TEST_EXPECTED_CONNECTION"] = json.dumps( - list(expected_connection) - ) - code = ( - "import json, os, runpy, sys\n" - + setup - + "\nsys.argv = [sys.argv[1]] + " - "json.loads(os.environ['TEST_CLI_ARGUMENTS'])\n" - "runpy.run_path(sys.argv[0], run_name='__main__')" - ) - return subprocess.run( - [sys.executable, "-c", code, str(CLI)], - text=True, - capture_output=True, - env=command_environment, - check=False, - ) - - def test_socket_fixture_rejects_wrong_endpoint_and_timeout(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ("status",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("wrong.example", 1, 0.5), - ) - - self.assertNotEqual(0, result.returncode) - self.assertIn("AssertionError: socket call mismatch", result.stderr) - - def test_no_subcommand_has_no_side_effect_and_reports_usage(self): + def test_no_subcommand_returns_2_without_side_effects(self): result = self.run_cli() self.assertEqual(2, result.returncode) self.assertEqual("", result.stdout) self.assertIn("укажите явную подкоманду", result.stderr) - self.assertFalse((self.config_home / "proxy-client").exists()) + self.assertEqual([], list(self.root.iterdir())) - def test_old_python_version_gets_clear_error_before_main_program(self): + def test_old_python_gets_clear_error(self): code = ( - "import runpy, sys; " - "sys.version_info = (3, 7, 9); " + "import runpy, sys; sys.version_info = (3, 7, 9); " "runpy.run_path(sys.argv[1], run_name='__main__')" ) result = subprocess.run( - [sys.executable, "-c", code, str(CLI)], - text=True, - capture_output=True, - check=False, + [sys.executable, "-c", code, str(CLI)], text=True, capture_output=True, check=False ) self.assertEqual(1, result.returncode) - self.assertEqual("", result.stdout) self.assertIn("требует Python 3.8", result.stderr) - self.assertIn("Python 3.7", result.stderr) - def test_proc_stat_parser_handles_spaces_and_closing_parentheses_in_comm(self): - module = runpy.run_path(str(CLI)) - parse_starttime = module["parse_proc_stat_starttime"] - tail = ["S"] + [str(value) for value in range(4, 22)] + ["987654"] + def test_status_env_and_run_against_reachable_proxy(self): + self.write_config(no_proxy_extra=" .example.test , git.example.test ") + status = self.run_cli("status") + self.assertEqual(0, status.returncode, status.stderr) + self.assertIn("Test proxy (external) доступен", status.stdout) - for comm in ("worker", "worker name", "worker name ) with parens"): - with self.subTest(comm=comm): - self.assertEqual( - 987654, - parse_starttime("321 ({}) ".format(comm) + " ".join(tail)), - ) - for malformed in ("321 worker S 1 2", "321 (worker) S 1 2"): - with self.subTest(malformed=malformed): - with self.assertRaises(ValueError): - parse_starttime(malformed) - - def test_process_permission_error_is_not_treated_as_dead(self): - module = runpy.run_path(str(CLI)) - with mock.patch.object(module["Path"], "stat", side_effect=PermissionError()): - self.assertTrue(module["process_alive"](12345)) - - def test_same_pid_cannot_be_a_healthy_managed_pair(self): - module = runpy.run_path(str(CLI)) - function_globals = module["managed_state_outcome"].__globals__ - state = {"supervisor_pid": 12345, "xray_pid": 12345, "boot_id": "boot"} - replacements = { - "read_boot_id": mock.Mock(return_value="boot"), - "process_alive": mock.Mock(return_value=True), - "supervisor_identity_matches": mock.Mock(return_value=True), - "xray_identity_matches": mock.Mock(return_value=True), - } - with mock.patch.dict(function_globals, replacements): - self.assertEqual("unverifiable", module["managed_state_outcome"](state)) - - def test_identity_reads_starttime_after_process_paths(self): - module = runpy.run_path(str(CLI)) - function_globals = module["xray_identity_matches"].__globals__ - state = { - "supervisor_pid": 101, - "supervisor_starttime": 201, - "supervisor_path": "/cli", - "xray_pid": 102, - "xray_starttime": 202, - "xray_path": "/xray", - } - - xray_events = [] - xray_replacements = { - "process_executable": lambda _pid: xray_events.append("exe") or "/xray", - "process_starttime": lambda _pid: xray_events.append("stat") or 202, - } - with mock.patch.dict(function_globals, xray_replacements): - self.assertTrue(module["xray_identity_matches"](state)) - self.assertEqual(["exe", "stat"], xray_events) - - supervisor_events = [] - supervisor_replacements = { - "process_cmdline": lambda _pid: supervisor_events.append("cmdline") - or ["python", "/cli"], - "process_argument_resolves_to": lambda *_args: supervisor_events.append( - "script" - ) - or True, - "process_starttime": lambda _pid: supervisor_events.append("stat") or 201, - } - with mock.patch.dict(function_globals, supervisor_replacements): - self.assertTrue(module["supervisor_identity_matches"](state)) - self.assertEqual(["cmdline", "script", "stat"], supervisor_events) - - def test_initial_child_starttime_retries_while_child_is_alive(self): - module = runpy.run_path(str(CLI)) - wait_for_starttime = module["wait_for_child_starttime"] - function_globals = wait_for_starttime.__globals__ - child = mock.Mock(pid=12345) - child.poll.return_value = None - replacements = { - "process_starttime": mock.Mock(side_effect=[None, 7654321]), - "time": mock.Mock(), - } - with mock.patch.dict(function_globals, replacements): - self.assertEqual(7654321, wait_for_starttime(child)) - - def test_broken_config_symlink_is_not_treated_as_missing_config(self): - config_dir = self.config_home / "proxy-client" - config_dir.mkdir() - (config_dir / "config").symlink_to(self.config_home / "missing-config") - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertIn("отсутствующий файл", result.stderr) - self.assertNotIn("только для type=xray", result.stderr) - - def test_missing_config_keeps_legacy_external_hiddify_endpoint(self): - result = self.run_cli("status", environment={"PROXY_PREFLIGHT_TIMEOUT": "0.05"}) - output = result.stdout + result.stderr - self.assertIn("Hiddify", output) - self.assertIn("hproxy.local:12334", output) - - def test_status_reports_reachable_external_proxy(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ("status",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("Test proxy (external) доступен", result.stdout) - self.assertEqual("", result.stderr) - - def test_env_prints_complete_proxy_environment_for_external_proxy(self): - self.write_config( - 23456, no_proxy_extra=" .example.test , git.example.test " - ) - result = self.run_cli_with_socket_setup( - ("env",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) - - proxy_url = "http://127.0.0.1:23456" - socks_url = "socks5://127.0.0.1:23456" - no_proxy = ( - "127.0.0.1,localhost,::1,[::1],.example.test,git.example.test" - ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual("", result.stderr) + env = self.run_cli("env") + proxy_url = "http://127.0.0.1:{}".format(self.port) + socks_url = "socks5://127.0.0.1:{}".format(self.port) + no_proxy = "127.0.0.1,localhost,::1,[::1],.example.test,git.example.test" + self.assertEqual(0, env.returncode, env.stderr) self.assertEqual( [ "HTTP_PROXY=" + proxy_url, @@ -323,991 +106,464 @@ class ProxyClientCliTest(unittest.TestCase): "all_proxy=" + socks_url, "no_proxy=" + no_proxy, ], - result.stdout.splitlines(), + env.stdout.splitlines(), ) - def test_env_brackets_ipv6_without_requiring_host_ipv6(self): - self.write_config(23456, address="2001:db8::1", no_proxy_extra="") - result = self.run_cli_with_socket_setup( - ("env",), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("2001:db8::1", 23456, 2.0), + code = ( + "import json, os, sys; print(json.dumps([sys.argv[1:], " + "os.environ['http_proxy']])); sys.exit(42)" ) + run = self.run_cli("run", "--", sys.executable, "-c", code, "a b", "--", "--flag") + self.assertEqual(42, run.returncode, run.stderr) + self.assertEqual([["a b", "--", "--flag"], proxy_url], json.loads(run.stdout)) - self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("HTTP_PROXY=http://[2001:db8::1]:23456\n", result.stdout) - self.assertIn("ALL_PROXY=socks5://[2001:db8::1]:23456\n", result.stdout) - - def test_scoped_ipv6_is_rejected_when_ipaddress_lacks_scope_support(self): - self.write_config(23456, address="fe80::1%eth0", no_proxy_extra="") - setup = ( - "import ipaddress\n" - "real_ip_address = ipaddress.ip_address\n" - "def python38_ip_address(value):\n" - " if '%' in value:\n" - " raise ValueError('scope IDs unsupported')\n" - " return real_ip_address(value)\n" - "ipaddress.ip_address = python38_ip_address\n" - + SUCCESSFUL_SOCKET_SETUP - ) - result = self.run_cli_with_socket_setup( - ("env",), - setup, - expected_connection=("fe80::1%eth0", 23456, 2.0), + killed = self.run_cli( + "run", "--", sys.executable, "-c", + "import os, signal; os.kill(os.getpid(), signal.SIGTERM)", ) + self.assertEqual(143, killed.returncode, killed.stderr) - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("address", result.stderr) - - def test_unreachable_env_prints_no_partial_output(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ("env",), - REFUSED_SOCKET_SETUP, - {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}, - expected_connection=("127.0.0.1", 23456, 0.1), - ) - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("недоступен", result.stderr) - - def test_unreachable_external_proxy_never_runs_child(self): - self.write_config(23456) - marker = self.config_home / "external-child-ran" - helper = self.config_home / "external-child" - helper.write_text( - "#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii" - ) - helper.chmod(0o755) - - result = self.run_cli_with_socket_setup( - ("run", "--", str(helper), str(marker)), - REFUSED_SOCKET_SETUP, - {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}, - expected_connection=("127.0.0.1", 23456, 0.1), - ) - - self.assertNotEqual(0, result.returncode) - self.assertIn("недоступен", result.stderr) + def test_unreachable_proxy_prints_nothing_and_runs_nothing(self): + self.write_config(port=closed_port()) + marker = self.root / "child-ran" + for command in (("env",), ("run", "--", "touch", str(marker))): + with self.subTest(command=command): + result = self.run_cli(*command) + self.assertEqual(1, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("недоступен", result.stderr) self.assertFalse(marker.exists()) - def test_xray_type_fails_closed_for_all_ticket_commands(self): - self.write_config(12334, proxy_type="xray") - marker = self.config_home / "child-ran" - helper = self.config_home / "child" - helper.write_text( - "#!/usr/bin/env bash\nprintf ran > \"$1\"\n", encoding="ascii" - ) - helper.chmod(0o755) - commands = [ - ("status",), - ("env",), - ("run", "--", str(helper), str(marker)), - ("start",), - ] - for command in commands: + def test_xray_type_without_running_xray_fails_without_fallback(self): + self.write_config(proxy_type="xray") + marker = self.root / "child-ran" + for command in (("status",), ("env",), ("run", "--", "touch", str(marker))): with self.subTest(command=command): result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) + self.assertEqual(1, result.returncode) self.assertEqual("", result.stdout) - self.assertIn("Ошибка:", result.stderr) - self.assertFalse(marker.exists()) + self.assertIn("не запущен", result.stderr) + self.assertFalse(marker.exists()) - def test_external_start_fails_without_starting_anything(self): - self.write_config(12334) + def test_start_refuses_external_type(self): + self.write_config() result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) + self.assertEqual(1, result.returncode) self.assertIn("только для type=xray", result.stderr) - def test_run_preserves_arguments_exit_code_and_child_only_environment(self): - self.write_config(23456) - code = ( - "import json, os, sys; " - "print(json.dumps({'args': sys.argv[1:], " - "'http': os.environ['HTTP_PROXY'], " - "'lower': os.environ['http_proxy']})); sys.exit(42)" - ) - original_http = os.environ.get("HTTP_PROXY") - result = self.run_cli_with_socket_setup( - ("run", "--", sys.executable, "-c", code, "a b", "--", "--flag"), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) + def test_missing_config_uses_hiddify_default(self): + result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="0.05") + self.assertIn("Hiddify (hproxy.local:12334)", result.stderr) - self.assertEqual(42, result.returncode, result.stderr) - child = json.loads(result.stdout) - self.assertEqual(["a b", "--", "--flag"], child["args"]) - expected_url = "http://127.0.0.1:23456" - self.assertEqual(expected_url, child["http"]) - self.assertEqual(expected_url, child["lower"]) - self.assertEqual(original_http, os.environ.get("HTTP_PROXY")) + def test_broken_config_symlink_is_an_error(self): + self.config_dir.mkdir() + (self.config_dir / "config").symlink_to(self.root / "missing") + result = self.run_cli("status") + self.assertEqual(1, result.returncode) + self.assertIn("отсутствующий файл", result.stderr) - def test_missing_run_command_returns_127(self): - self.write_config(12334) - result = self.run_cli("run", "--", "definitely-no-such-command-xyz") - self.assertEqual(127, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("не найдена в PATH", result.stderr) - - def test_run_maps_child_signal_to_shell_exit_code(self): - self.write_config(23456) - result = self.run_cli_with_socket_setup( - ( - "run", - "--", - sys.executable, - "-c", - "import os, signal; os.kill(os.getpid(), signal.SIGTERM)", - ), - SUCCESSFUL_SOCKET_SETUP, - expected_connection=("127.0.0.1", 23456, 2.0), - ) - self.assertEqual(143, result.returncode, result.stderr) - - def test_run_requires_explicit_separator_and_command(self): - self.write_config(12334) + def test_run_usage_errors(self): + self.write_config() + missing = self.run_cli("run", "--", "definitely-no-such-command-xyz") + self.assertEqual(127, missing.returncode) + self.assertIn("не найдена в PATH", missing.stderr) for command in (("run",), ("run", "true"), ("run", "--")): with self.subTest(command=command): - result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) - self.assertIn("run -- COMMAND", result.stderr) + self.assertIn("run -- COMMAND", self.run_cli(*command).stderr) - def test_invalid_preflight_timeout_is_rejected_clearly(self): - self.write_config(12334) - result = self.run_cli( - "status", environment={"PROXY_PREFLIGHT_TIMEOUT": "not-a-number"} - ) - self.assertNotEqual(0, result.returncode) + def test_invalid_preflight_timeout_is_rejected(self): + self.write_config() + result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="not-a-number") + self.assertEqual(1, result.returncode) self.assertIn("положительным числом", result.stderr) - def test_ini_rejects_structure_key_and_value_grammar_errors(self): - valid = ( - "[proxy]\n" - "type = external\n" - "name = Test\n" - "address = 127.0.0.1\n" - "port = 12334\n" - "no_proxy_extra =\n" + +class MachineConfigTest(unittest.TestCase): + VALID = ( + "[proxy]\ntype = external\nname = Test\naddress = 127.0.0.1\n" + "port = 12334\nno_proxy_extra =\n" + ) + + def parse(self, text): + return MODULE["parse_config"](text) + + def test_valid_config(self): + config = self.parse(self.VALID.replace("no_proxy_extra =", "no_proxy_extra = a, b")) + self.assertEqual( + ("external", "Test", "127.0.0.1", 12334, ("a", "b")), + (config.proxy_type, config.name, config.address, config.port, config.no_proxy_extra), ) - invalid_configs = { - "unknown section": valid + "[other]\nkey = value\n", - "unknown key": valid + "extra = value\n", - "duplicate key": valid + "port = 12335\n", - "missing key": valid.replace("name = Test\n", ""), - "empty name": valid.replace("name = Test", "name = "), - "empty address": valid.replace("address = 127.0.0.1", "address = "), - "port zero": valid.replace("port = 12334", "port = 0"), - "port high": valid.replace("port = 12334", "port = 65536"), - "port syntax": valid.replace("port = 12334", "port = +80"), - "empty no-proxy item": valid.replace( + + def test_invalid_structure_and_values(self): + cases = { + "unknown section": self.VALID + "[other]\nkey = value\n", + "unknown key": self.VALID + "extra = value\n", + "duplicate key": self.VALID + "port = 12335\n", + "missing key": self.VALID.replace("name = Test\n", ""), + "bad type": self.VALID.replace("type = external", "type = socks"), + "empty name": self.VALID.replace("name = Test", "name = "), + "empty address": self.VALID.replace("address = 127.0.0.1", "address ="), + "port zero": self.VALID.replace("port = 12334", "port = 0"), + "port high": self.VALID.replace("port = 12334", "port = 65536"), + "port syntax": self.VALID.replace("port = 12334", "port = +80"), + "empty no-proxy item": self.VALID.replace( "no_proxy_extra =", "no_proxy_extra = one.example,,two.example" ), } - for label, content in invalid_configs.items(): - with self.subTest(label=label): - self.write_raw_config(content) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertEqual("", result.stdout) - self.assertIn("Ошибка:", result.stderr) + for label, text in cases.items(): + with self.subTest(label): + # Ошибки синтаксиса INI приходят от configparser; load_config + # добавляет к ним путь к файлу. + with self.assertRaises((ProxyError, MODULE["configparser"].Error)): + self.parse(text) - def test_address_accepts_ip_literals_and_simple_ascii_names(self): - valid_addresses = ( - "127.0.0.1", - "2001:db8::1", - "proxy-1.example", - ".leading.example", - "trailing.example.", - "bad..example", - "-bad.example", - "bad-.example", - ) - for address in valid_addresses: - with self.subTest(address=address): - self.write_config(12334, address=address) - result = self.run_cli("start") - self.assertIn("только для type=xray", result.stderr) + def test_addresses(self): + for address in ("127.0.0.1", "2001:db8::1", "proxy-1.example", "trailing.example."): + with self.subTest(address): + self.assertEqual( + address, self.parse(self.VALID.replace("127.0.0.1", address)).address + ) + for address in ("http://proxy", "[::1]", "has space", "proxy/path", "user@proxy", + "прокси.example", "fe80::1%eth0"): + with self.subTest(address): + with self.assertRaisesRegex(ProxyError, "address"): + self.parse(self.VALID.replace("127.0.0.1", address)) - invalid_addresses = ( - "http://proxy.example", - "[::1]", - "has space", - "proxy/path", - "user@proxy", - "прокси.example", - "fe80::1%eth0", + def test_load_error_names_config_file(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "proxy-client/config" + path.parent.mkdir() + path.write_text("not an ini\n", encoding="utf-8") + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": root}): + with self.assertRaisesRegex(ProxyError, str(path)): + MODULE["load_config"]() + + +UUID = "11111111-2222-4333-8444-555555555555" +ENCRYPTION = "mlkem768x25519plus.native.0rtt.SYNTHETIC_CLIENT_KEY" +REALITY = ( + "vless://{}@example.invalid:443?type=tcp&security=reality&encryption=none" + "&flow=xtls-rprx-vision&sni=front.example.invalid&fp=chrome" + "&pbk=SYNTHETIC_PUBLIC_KEY&sid=SYNTHETIC_SHORT_ID&headerType=none" +).format(UUID) +XHTTP = ( + "vless://{}@example.invalid:443?type=xhttp&security=tls&encryption=none" + "&sni=cdn.example.invalid&fp=chrome&host=cdn.example.invalid&path=%2Fp" +).format(UUID) +# Новый формат Hiddify: «/» перед «?», VLESS-шифрование и flow поверх XHTTP. +HIDDIFY_XHTTP = ( + "vless://{}@192.0.2.1:443/?hiddify=1&fp=chrome&sni=cdn.example.invalid" + "&security=tls&alpn=h3&type=xhttp&mode=auto&path=%2Fp&host=cdn.example.invalid" + "&extra={}&flow=xtls-rprx-vision&encryption={}#xhttp%20vless%20QUIC" +).format(UUID, quote('{"downloadSettings":{"address":"cdn.example.invalid"}}', safe=""), + ENCRYPTION) + + +def outbound(url): + profile = MODULE["parse_vless"](url) + return MODULE["build_xray_config"](profile, "127.0.0.1", 12334)["outbounds"][0] + + +class VlessProfileTest(unittest.TestCase): + def test_reality_mapping(self): + result = outbound(REALITY + "&host=ignored.example&alpn=h2") + self.assertEqual("xtls-rprx-vision", result["settings"]["flow"]) + self.assertEqual( + { + "network": "raw", + "security": "reality", + "realitySettings": { + "serverName": "front.example.invalid", + "fingerprint": "chrome", + "password": "SYNTHETIC_PUBLIC_KEY", + "shortId": "SYNTHETIC_SHORT_ID", + }, + }, + result["streamSettings"], ) - for address in invalid_addresses: - with self.subTest(address=address): - self.write_config(12334, address=address) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertNotIn("только для type=xray", result.stderr) - self.assertIn("address", result.stderr) + + def test_xhttp_mapping_with_all_optional_fields(self): + pcs = "a" * 64 + "%2C" + "B" * 64 + url = XHTTP + "&alpn=http%2F1.1%2Ch2&mode=auto&pcs={}&vcn=a.example%2Cb.example" \ + "&extra=%7B%22x%22%3A1%7D&core=xray".format(pcs) + stream = outbound(url)["streamSettings"] + self.assertEqual( + {"host": "cdn.example.invalid", "path": "/p", "mode": "auto", "extra": {"x": 1}}, + stream["xhttpSettings"], + ) + self.assertEqual( + { + "serverName": "cdn.example.invalid", + "fingerprint": "chrome", + "alpn": ["http/1.1", "h2"], + "pinnedPeerCertSha256": "a" * 64 + "," + "B" * 64, + "verifyPeerCertByName": "a.example,b.example", + }, + stream["tlsSettings"], + ) + + def test_hiddify_xhttp_with_vless_encryption(self): + result = outbound(HIDDIFY_XHTTP) + self.assertEqual(ENCRYPTION, result["settings"]["encryption"]) + self.assertEqual("xtls-rprx-vision", result["settings"]["flow"]) + self.assertEqual("192.0.2.1", result["settings"]["address"]) + self.assertEqual(["h3"], result["streamSettings"]["tlsSettings"]["alpn"]) + self.assertIn("downloadSettings", result["streamSettings"]["xhttpSettings"]["extra"]) + + def test_alpn_values(self): + for value in ("http/1.1", "h2", "h3", "h2,h3"): + with self.subTest(value): + tls = outbound(XHTTP + "&alpn=" + quote(value, safe=""))["streamSettings"][ + "tlsSettings" + ] + self.assertEqual(value.split(","), tls["alpn"]) + + def test_legacy_insecure_is_accepted_ignored_and_flagged_when_true(self): + cases = ( + ("", False), + ("&allowInsecure=false", False), + ("&allowInsecure=0&insecure=0", False), + ("&insecure=1", True), + ("&allowInsecure=TRUE&insecure=false", True), + ) + for base in (XHTTP, REALITY): + for fields, flagged in cases: + with self.subTest(base=base[-20:], fields=fields): + profile = MODULE["parse_vless"](base + fields) + self.assertEqual(flagged, profile.insecure) + config = json.dumps(MODULE["build_xray_config"](profile, "127.0.0.1", 1)) + self.assertNotIn("nsecure", config) + + def test_rejected_links(self): + cases = { + "unknown parameter": REALITY + "&mystery=value", + "duplicate parameter": REALITY + "&sni=again.example", + "headerType http": REALITY.replace("headerType=none", "headerType=http"), + "other flow": REALITY.replace("flow=xtls-rprx-vision", "flow=other"), + "bad percent-encoding": REALITY.replace("sni=front.example.invalid", "sni=bad%FF"), + "non-root path": REALITY.replace(":443?", ":443/path?"), + "grpc": REALITY.replace("type=tcp", "type=grpc"), + "missing pbk": REALITY.replace("&pbk=SYNTHETIC_PUBLIC_KEY", ""), + "xhttp field in reality": REALITY + "&path=%2Fp", + "xhttp with reality": XHTTP.replace("security=tls", "security=reality"), + "flow over xhttp without encryption": XHTTP + "&flow=xtls-rprx-vision", + "unknown encryption": HIDDIFY_XHTTP.replace("mlkem768x25519plus.", "aes-128-gcm."), + "unknown alpn": XHTTP + "&alpn=h4", + "empty alpn item": XHTTP + "&alpn=h2%2C", + "short pcs": XHTTP + "&pcs=abc", + "empty vcn item": XHTTP + "&vcn=a.example%2C", + "extra not an object": XHTTP + "&extra=%5B1%5D", + "extra not json": XHTTP + "&extra=%7Bx", + "bad uuid": REALITY.replace(UUID, "not-a-uuid"), + "not vless": REALITY.replace("vless://", "vmess://"), + } + for label, url in cases.items(): + with self.subTest(label): + with self.assertRaises(ProxyError): + MODULE["parse_vless"](url) class XrayInstallTest(unittest.TestCase): def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.root = Path(self.temporary.name) + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) self.home = self.root / "home" - self.state_home = self.root / "state" self.home.mkdir() + self.destination = self.home / ".local/bin/xray" self.environment = { "HOME": str(self.home), - "XDG_STATE_HOME": str(self.state_home), - "PATH": str(self.home / ".local/bin"), + "XDG_STATE_HOME": str(self.root / "state"), + "PATH": str(self.destination.parent), } - self.module = runpy.run_path(str(CLI)) - self.destination = self.home / ".local/bin/xray" - self.archive = self.root / "fixture.zip" - self.write_archive("installed") - - def tearDown(self): - self.temporary.cleanup() + self.archive = self.make_archive(self.fake_xray("installed")) @staticmethod - def fake_xray(label="fixture", version="26.3.27"): - return ( - "#!/bin/sh\n" - "if [ \"$1\" = version ]; then\n" - " printf '%s\\n' 'Xray {} ({})'\n" - " exit 0\n" - "fi\n" - "exit 64\n".format(version, label) + def fake_xray(label, version="26.3.27"): + return "#!/bin/sh\n[ \"$1\" = version ] && echo 'Xray {} ({})'\n".format( + version, label ).encode("ascii") - def write_archive(self, label="fixture", version="26.3.27", member="xray"): - with zipfile.ZipFile(str(self.archive), "w") as archive: - archive.writestr(member, self.fake_xray(label, version)) + def make_archive(self, content, member="xray"): + path = self.root / "archive-{}.zip".format(hashlib.sha256(content).hexdigest()[:8]) + with zipfile.ZipFile(str(path), "w") as archive: + archive.writestr(member, content) + return path.read_bytes() - @staticmethod - def official_digest(archive_hash): - return ( - "MD5= {}\n" - "SHA1= {}\n" - "SHA2-256= {}\n" - "SHA2-512= {}\n".format( - "1" * 32, - "2" * 40, - archive_hash, - "3" * 128, + def downloader(self, archive=None, digest=None): + archive = self.archive if archive is None else archive + if digest is None: + digest = ( + "MD5= {}\nSHA1= {}\nSHA2-256= {}\n".format( + "1" * 32, "2" * 40, hashlib.sha256(archive).hexdigest() + ) ) - ) - - def downloader(self, bad_hash=False, digest_text=None, fail_after_archive=False): calls = [] - def download(url, destination, _max_bytes): + def download(url, destination): calls.append(url) if url.endswith(".dgst"): - if fail_after_archive: - raise self.module["ProxyError"]("инъецированная ошибка загрузки") - archive_hash = hashlib.sha256(self.archive.read_bytes()).hexdigest() - if bad_hash: - archive_hash = "0" * 64 - text = digest_text - if text is None: - text = self.official_digest(archive_hash) - destination.write_text(text, encoding="ascii") + destination.write_text(digest, encoding="ascii") else: - destination.write_bytes(self.archive.read_bytes()) + destination.write_bytes(archive) return download, calls def install(self, replace=False, downloader=None, machine="x86_64", path=None): if downloader is None: - downloader, _calls = self.downloader() + downloader = self.downloader()[0] environment = dict(self.environment) if path is not None: environment["PATH"] = path - globals_ = self.module["command_install"].__globals__ - with mock.patch.dict(os.environ, environment, clear=False): - with mock.patch.object(globals_["platform"], "machine", return_value=machine): - return self.module["command_install"](replace, downloader) + with mock.patch.dict(os.environ, environment), \ + mock.patch("platform.machine", return_value=machine), \ + mock.patch("sys.stdout") as stdout: + MODULE["command_install"](replace, downloader) + return "".join(call.args[0] for call in stdout.write.call_args_list) - def write_destination(self, content=None, executable=True): + def write_destination(self, content=b"old sentinel", executable=True): self.destination.parent.mkdir(parents=True, exist_ok=True) - self.destination.write_bytes(content or self.fake_xray("existing")) + self.destination.write_bytes(content) self.destination.chmod(0o755 if executable else 0o644) - def tree_snapshot(self): - snapshot = {} - for path in self.root.rglob("*"): - relative = path.relative_to(self.root).as_posix() - info = path.lstat() - mode = info.st_mode & 0o777 - if path.is_symlink(): - snapshot[relative] = ("symlink", mode, os.readlink(str(path))) - elif path.is_dir(): - snapshot[relative] = ("directory", mode) - elif path.is_file(): - digest = hashlib.sha256(path.read_bytes()).hexdigest() - snapshot[relative] = ("file", mode, digest) - else: - snapshot[relative] = ("other", mode) - return snapshot + def assert_destination_kept(self, content=b"old sentinel"): + self.assertEqual(content, self.destination.read_bytes()) + self.assertEqual([], list(self.destination.parent.glob(".xray-install-*"))) - def tree_snapshot_without_install_lock(self): - snapshot = self.tree_snapshot() - for path in ("state/proxy-client/lock", "state/proxy-client", "state"): - snapshot.pop(path, None) - return snapshot - - def assert_no_staging(self): - if self.destination.parent.exists(): - self.assertEqual( - [], list(self.destination.parent.glob(".xray-install-*")) - ) - - def assert_install_lock_released(self): - lock_path = self.state_home / "proxy-client/lock" - with lock_path.open("r+", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def test_architecture_mapping_is_pure_and_rejects_unknown(self): - asset_for = self.module["xray_asset_for_architecture"] - for machine in ("x86_64", "amd64"): - self.assertEqual("Xray-linux-64.zip", asset_for(machine)) - for machine in ("aarch64", "arm64"): - self.assertEqual("Xray-linux-arm64-v8a.zip", asset_for(machine)) - with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): - asset_for("riscv64") - - def test_valid_digest_installs_verified_executable_and_only_expected_files(self): + def test_fresh_install(self): downloader, calls = self.downloader() - self.assertEqual(0, self.install(downloader=downloader)) - + output = self.install(downloader=downloader) + self.assertIn("установлен", output) self.assertEqual(0o755, self.destination.stat().st_mode & 0o777) self.assertIn(b"installed", self.destination.read_bytes()) - self.assertEqual(2, len(calls)) - self.assertTrue(calls[0].endswith("/Xray-linux-64.zip")) - self.assertEqual(calls[0] + ".dgst", calls[1]) - self.assertEqual( - { - "fixture.zip", - "home", - "home/.local", - "home/.local/bin", - "home/.local/bin/xray", - "state", - "state/proxy-client", - "state/proxy-client/lock", - }, - set(self.tree_snapshot()), - ) - self.assert_no_staging() + self.assertTrue(calls[0].endswith("/v26.3.27/Xray-linux-64.zip")) + self.assertEqual([calls[0], calls[0] + ".dgst"], calls) + self.assertEqual(["xray"], [path.name for path in self.destination.parent.iterdir()]) - def test_platform_machine_selects_arm64_asset(self): + def test_architecture_selects_asset(self): downloader, calls = self.downloader() - self.assertEqual( - 0, self.install(downloader=downloader, machine="aarch64") - ) + self.install(downloader=downloader, machine="aarch64") self.assertTrue(calls[0].endswith("/Xray-linux-arm64-v8a.zip")) - self.assertEqual(calls[0] + ".dgst", calls[1]) + with self.assertRaisesRegex(ProxyError, "не поддерживается"): + self.install(replace=True, downloader=self.fail_download, machine="riscv64") - def test_candidate_is_private_while_zip_bytes_are_written(self): - os_module = self.module["extract_xray_candidate"].__globals__["os"] - real_fdopen = os_module.fdopen - observed_modes = [] + @staticmethod + def fail_download(_url, _destination): + raise AssertionError("сеть не должна использоваться") - def checked_fdopen(descriptor, *arguments, **keywords): - descriptor_path = os.readlink("/proc/self/fd/{}".format(descriptor)) - if Path(descriptor_path).name == "xray": - observed_modes.append(os.fstat(descriptor).st_mode & 0o777) - return real_fdopen(descriptor, *arguments, **keywords) + def test_digest_is_compared_case_insensitively(self): + digest = "SHA2-256= {}\n".format(hashlib.sha256(self.archive).hexdigest().upper()) + self.install(downloader=self.downloader(digest=digest)[0]) + self.assertTrue(self.destination.exists()) - with mock.patch.object( - os_module, "fdopen", side_effect=checked_fdopen - ): - self.assertEqual(0, self.install()) + def test_failures_keep_old_binary_and_remove_staging(self): + cases = { + "wrong hash": self.downloader(digest="SHA2-256= " + "0" * 64 + "\n")[0], + "no SHA2-256 line": self.downloader(digest="MD5= " + "1" * 32 + "\n")[0], + "corrupt zip": self.downloader(archive=b"not a zip")[0], + "no xray in zip": self.downloader( + archive=self.make_archive(b"data", member="other") + )[0], + "wrong version": self.downloader( + archive=self.make_archive(self.fake_xray("old", "26.3.26")) + )[0], + } - self.assertEqual([0o600], observed_modes) - self.assertEqual(0o755, self.destination.stat().st_mode & 0o777) + def interrupted(_url, destination): + destination.write_bytes(b"partial") + raise KeyboardInterrupt - def test_bad_hash_and_digest_format_preserve_destination_before_extraction(self): - valid_hash = "a" * 64 - cases = ( - self.downloader(bad_hash=True)[0], - self.downloader(digest_text="SHA256= " + valid_hash + "\n")[0], - self.downloader( - digest_text=self.official_digest(valid_hash).replace( - "SHA2-256= " + valid_hash + "\n", "" - ) - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash) - + "SHA2-256= " - + valid_hash - + "\n" - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash) + "garbage\n" - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash).replace( - valid_hash, "a" * 63 - ) - )[0], - self.downloader( - digest_text=self.official_digest(valid_hash).replace( - valid_hash, "a" * 65 - ) - )[0], - ) - for downloader in cases: - with self.subTest(downloader=downloader): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with self.assertRaises(self.module["ProxyError"]): + for label, downloader in cases.items(): + with self.subTest(label): + self.write_destination() + with self.assertRaises(ProxyError): self.install(replace=True, downloader=downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) + self.assert_destination_kept() + with self.assertRaises(KeyboardInterrupt): + self.install(replace=True, downloader=interrupted) + self.assert_destination_kept() - def test_bad_or_truncated_archive_is_rejected_before_zip_open(self): - full_archive = self.archive.read_bytes() + def test_existing_right_version_is_kept_without_network(self): + self.write_destination(self.fake_xray("existing")) + output = self.install(downloader=self.fail_download) + self.assertIn("уже установлен", output) + self.assert_destination_kept(self.fake_xray("existing")) - def truncated_downloader(url, destination, _max_bytes): - if url.endswith(".dgst"): - digest = hashlib.sha256(full_archive).hexdigest() - destination.write_text(self.official_digest(digest), encoding="ascii") - else: - destination.write_bytes(full_archive[: len(full_archive) // 2]) + def test_other_existing_file_needs_replace(self): + for content, executable in ((b"broken", True), (self.fake_xray("x"), False)): + with self.subTest(content=content, executable=executable): + self.write_destination(content, executable) + with self.assertRaisesRegex(ProxyError, "--replace"): + self.install(downloader=self.fail_download) + self.install(replace=True) + self.assertIn(b"installed", self.destination.read_bytes()) - zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] - with mock.patch.object( - zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до SHA-256") - ) as opened: - with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): - self.install(downloader=truncated_downloader) - opened.assert_not_called() - self.assertFalse(self.destination.exists()) - self.assert_no_staging() - - def test_real_downloader_installs_complete_declared_responses(self): - archive = self.archive.read_bytes() - digest = self.official_digest(hashlib.sha256(archive).hexdigest()).encode( - "ascii" - ) - responses = [ - FakeUrlResponse(archive, content_length=len(archive)), - FakeUrlResponse(digest, content_length=len(digest)), - ] - urllib_request = self.module["download_file"].__globals__["urllib"].request - - with mock.patch.object(urllib_request, "urlopen", side_effect=responses): - self.assertEqual( - 0, self.install(downloader=self.module["download_file"]) - ) - - def test_real_downloader_refuses_length_errors_and_releases_install(self): - archive = self.archive.read_bytes() - cases = ( - ( - "early EOF", - FakeUrlResponse(archive, content_length=len(archive) + 1), - ), - ( - "extra bytes", - FakeUrlResponse(archive, content_length=len(archive) - 1), - ), - ( - "declared oversized", - FakeUrlResponse( - b"", - content_length=self.module["MAX_ARCHIVE_BYTES"] + 1, - ), - ), - ("url error", urllib.error.URLError("offline")), - ( - "http error", - FakeUrlResponse(read_error=http.client.HTTPException("broken HTTP")), - ), - ( - "incomplete read", - FakeUrlResponse( - read_error=http.client.IncompleteRead(b"partial", 10) - ), - ), - ) - urllib_request = self.module["download_file"].__globals__["urllib"].request - - for label, outcome in cases: - with self.subTest(label=label): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with mock.patch.object(urllib_request, "urlopen", side_effect=[outcome]): - with self.assertRaises(self.module["ProxyError"]): - self.install( - replace=True, downloader=self.module["download_file"] - ) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assert_install_lock_released() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_real_downloader_counts_limit_plus_one_without_content_length(self): - urllib_request = self.module["download_file"].__globals__["urllib"].request - globals_ = self.module["command_install"].__globals__ - sentinel = b"destination sentinel" - self.write_destination(sentinel) - - with mock.patch.dict(globals_, {"MAX_ARCHIVE_BYTES": 8}): - response = FakeUrlResponse(b"123456789") - with mock.patch.object(urllib_request, "urlopen", return_value=response): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install( - replace=True, downloader=self.module["download_file"] - ) - - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assert_install_lock_released() - - def test_uppercase_digest_is_rejected_before_zip_open(self): - digest = hashlib.sha256(self.archive.read_bytes()).hexdigest().upper() - downloader, _calls = self.downloader( - digest_text=self.official_digest(digest) - ) - zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"] - with mock.patch.object( - zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до digest") - ) as opened: - with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"): - self.install(downloader=downloader) - opened.assert_not_called() - self.assertFalse(self.destination.exists()) - self.assert_no_staging() - - def test_existing_verified_install_is_noop_without_downloader(self): - self.write_destination() - downloader = mock.Mock(side_effect=AssertionError("network used")) - self.assertEqual(0, self.install(downloader=downloader)) - downloader.assert_not_called() - - def test_existing_version_gate_keeps_timeout_and_avoids_downloader(self): - self.write_destination() - downloader = mock.Mock(side_effect=AssertionError("network used")) - subprocess_module = self.module["xray_version"].__globals__["subprocess"] - timeout = subprocess.TimeoutExpired([str(self.destination), "version"], 5) - - with mock.patch.object(subprocess_module, "run", side_effect=timeout) as run: - with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): - self.install(downloader=downloader) - - downloader.assert_not_called() - self.assertEqual(5, run.call_args.kwargs["timeout"]) - self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) - - def test_replace_reinstalls_verified_existing_file(self): - self.write_destination(self.fake_xray("old")) - downloader, calls = self.downloader() - self.assertEqual(0, self.install(replace=True, downloader=downloader)) - self.assertIn(b"installed", self.destination.read_bytes()) - self.assertEqual(2, len(calls)) - - def test_destination_table_for_symlink_directory_and_bad_files(self): - self.destination.parent.mkdir(parents=True) + def test_replace_does_not_write_through_symlink(self): target = self.root / "target" target.write_bytes(b"target sentinel") + self.destination.parent.mkdir(parents=True) self.destination.symlink_to(target) - with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): - self.install() - self.assertTrue(self.destination.is_symlink()) - self.assertEqual(0, self.install(replace=True)) + with self.assertRaisesRegex(ProxyError, "--replace"): + self.install(downloader=self.fail_download) + self.install(replace=True) self.assertFalse(self.destination.is_symlink()) self.assertEqual(b"target sentinel", target.read_bytes()) - self.destination.unlink() - self.destination.mkdir() + def test_directory_destination_is_refused(self): + self.destination.mkdir(parents=True) for replace in (False, True): - with self.subTest(directory=True, replace=replace): - with self.assertRaisesRegex(self.module["ProxyError"], "каталог"): - self.install(replace=replace) - self.destination.rmdir() + with self.subTest(replace=replace): + with self.assertRaisesRegex(ProxyError, "каталог"): + self.install(replace=replace, downloader=self.fail_download) - for content, executable in ((b"broken", True), (self.fake_xray(), False)): - with self.subTest(content=content, executable=executable): - self.write_destination(content, executable) - with self.assertRaisesRegex(self.module["ProxyError"], "--replace"): - self.install() - self.assertEqual(0, self.install(replace=True)) - self.assertIn(b"installed", self.destination.read_bytes()) - self.destination.unlink() - - def test_parent_symlink_cannot_redirect_install_outside_home(self): - outside = self.root / "outside" - outside.mkdir() - (self.home / ".local").symlink_to(outside, target_is_directory=True) - downloader = mock.Mock() - - with self.assertRaisesRegex(self.module["ProxyError"], "домашнего каталога"): - self.install(downloader=downloader) - - downloader.assert_not_called() - self.assertEqual([], list(outside.iterdir())) - - def test_managed_state_guard_only_allows_absent_or_stale(self): - guard = self.module["require_install_safe_managed_state"] - globals_ = guard.__globals__ - present = mock.Mock() - present.lstat.return_value = mock.Mock() - with mock.patch.dict( - globals_, - { - "state_path": mock.Mock(return_value=present), - "read_managed_state": mock.Mock(return_value={"state": "fixture"}), - }, - ): - for outcome in ("healthy", "orphan", "unverifiable"): - with self.subTest(outcome=outcome): - globals_["managed_state_outcome"] = mock.Mock(return_value=outcome) - with self.assertRaisesRegex(self.module["ProxyError"], "stop"): - guard() - globals_["managed_state_outcome"] = mock.Mock(return_value="stale") - guard() - - absent = mock.Mock() - absent.lstat.side_effect = FileNotFoundError() - with mock.patch.dict( - globals_, {"state_path": mock.Mock(return_value=absent)} - ): - guard() - - def test_command_classifies_managed_state_before_even_noop_destination(self): - self.write_destination() - downloader = mock.Mock(side_effect=AssertionError("network used")) - guard = mock.Mock( - side_effect=self.module["ProxyError"]( - "managed Xray работает; сначала выполните proxy-client stop" - ) - ) - globals_ = self.module["command_install"].__globals__ - - with mock.patch.dict( - globals_, {"require_install_safe_managed_state": guard} - ): - with self.assertRaisesRegex(self.module["ProxyError"], "stop"): - self.install(downloader=downloader) - - guard.assert_called_once_with() - downloader.assert_not_called() - self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes()) - - def test_download_extraction_and_version_errors_are_atomic_and_clean_staging(self): - failures = [] - failures.append(self.downloader(fail_after_archive=True)[0]) - missing_member = self.root / "missing-member.zip" - with zipfile.ZipFile(str(missing_member), "w") as archive: - archive.writestr("not-xray", b"data") - - def missing_member_downloader(url, destination, _max_bytes): - source = missing_member if not url.endswith(".dgst") else None - if source is not None: - destination.write_bytes(source.read_bytes()) - else: - digest = hashlib.sha256(missing_member.read_bytes()).hexdigest() - destination.write_text(self.official_digest(digest), encoding="ascii") - - failures.append(missing_member_downloader) - self.write_archive("wrong", version="26.3.26") - failures.append(self.downloader()[0]) - - for downloader in failures: - with self.subTest(downloader=downloader): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with self.assertRaises(self.module["ProxyError"]): - self.install(replace=True, downloader=downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_corrupt_zip_with_matching_digest_is_atomic_and_cleaned(self): - corrupt = b"not a ZIP archive" - - def corrupt_downloader(url, destination, _max_bytes): - if url.endswith(".dgst"): - digest = hashlib.sha256(corrupt).hexdigest() - destination.write_text(self.official_digest(digest), encoding="ascii") - else: - destination.write_bytes(corrupt) - - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - with self.assertRaisesRegex(self.module["ProxyError"], "извлечь"): - self.install(replace=True, downloader=corrupt_downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_injected_oversized_archive_and_digest_are_bounded(self): - globals_ = self.module["command_install"].__globals__ - cases = ( - ("MAX_ARCHIVE_BYTES", len(self.archive.read_bytes()) - 1, self.downloader()[0]), - ( - "MAX_DIGEST_BYTES", - 8, - self.downloader(digest_text=self.official_digest("a" * 64))[0], - ), - ) - for constant, limit, downloader in cases: - with self.subTest(constant=constant): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - with mock.patch.dict(globals_, {constant: limit}): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install(replace=True, downloader=downloader) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - - def test_zip_member_declared_over_limit_is_rejected_before_extraction(self): - globals_ = self.module["command_install"].__globals__ - binary_size = len(self.fake_xray()) - sentinel = b"destination sentinel" - self.write_destination(sentinel) - - with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": binary_size - 1}): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install(replace=True) - - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - - def test_streamed_zip_output_cannot_exceed_limit_even_if_metadata_lies(self): - class FakeInfo: - filename = "xray" - file_size = 8 - - @staticmethod - def is_dir(): - return False - - class FakeArchive: - def __enter__(self): - return self - - def __exit__(self, _type, _value, _traceback): - return False - - @staticmethod - def infolist(): - return [FakeInfo()] - - @staticmethod - def open(_member): - return FakeUrlResponse(b"123456789") - - globals_ = self.module["command_install"].__globals__ - zip_file = globals_["zipfile"] - sentinel = b"destination sentinel" - self.write_destination(sentinel) - with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": 8}): - with mock.patch.object(zip_file, "ZipFile", return_value=FakeArchive()): - with self.assertRaisesRegex(self.module["ProxyError"], "размер"): - self.install(replace=True) - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - - def test_interruption_preserves_destination_cleans_staging_and_releases_lock(self): - sentinel = b"destination sentinel" - self.write_destination(sentinel) - before = self.tree_snapshot_without_install_lock() - - def interrupted_downloader(_url, destination, _max_bytes): - destination.write_bytes(b"partial") - raise KeyboardInterrupt() - - with self.assertRaises(KeyboardInterrupt): - self.install(replace=True, downloader=interrupted_downloader) - - self.assertEqual(sentinel, self.destination.read_bytes()) - self.assert_no_staging() - lock_path = self.state_home / "proxy-client/lock" - with lock_path.open("r+", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - self.assertEqual(before, self.tree_snapshot_without_install_lock()) - - def test_start_lock_is_held_during_download_and_through_replace(self): - downloader, _calls = self.downloader() - lock_path = self.state_home / "proxy-client/lock" - - def assert_lock_held(): - with lock_path.open("r+", encoding="ascii") as competing: - with self.assertRaises(BlockingIOError): - fcntl.flock(competing, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def checked_downloader(url, destination, max_bytes): - assert_lock_held() - downloader(url, destination, max_bytes) - - os_module = self.module["command_install"].__globals__["os"] - real_replace = os_module.replace - - def checked_replace(source, destination): - real_replace(source, destination) - assert_lock_held() - - with mock.patch.object(os_module, "replace", side_effect=checked_replace): - self.assertEqual(0, self.install(downloader=checked_downloader)) - - with lock_path.open("r+", encoding="ascii") as released: - fcntl.flock(released, fcntl.LOCK_EX | fcntl.LOCK_NB) - - def test_unsupported_architecture_refuses_before_downloading(self): - downloader = mock.Mock() - with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"): - self.install(downloader=downloader, machine="riscv64") - downloader.assert_not_called() - self.assertFalse(self.destination.exists()) - - def test_path_missing_or_competing_returns_error_without_rollback(self): - cases = (("empty", False), ("competitor", True)) - for directory_name, competing in cases: - with self.subTest(directory_name=directory_name): - path_dir = self.root / directory_name - path_dir.mkdir() - if competing: - competitor = path_dir / "xray" - competitor.write_bytes(self.fake_xray("competitor")) - competitor.chmod(0o755) - path = str(path_dir) + os.pathsep + str(self.destination.parent) - else: - path = str(path_dir) - with self.assertRaisesRegex(self.module["ProxyError"], "PATH"): + def test_path_that_misses_installed_xray_is_an_error(self): + competitor_dir = self.root / "competitor" + competitor_dir.mkdir() + (competitor_dir / "xray").write_bytes(self.fake_xray("competitor")) + (competitor_dir / "xray").chmod(0o755) + for path in (str(self.root), str(competitor_dir) + os.pathsep + str(self.destination.parent)): + with self.subTest(path=path): + with self.assertRaisesRegex(ProxyError, "PATH"): self.install(path=path) self.assertTrue(self.destination.is_file()) self.destination.unlink() - def test_busy_install_hint_uses_exception_type_not_message_text(self): - globals_ = self.module["acquire_install_lock"].__globals__ - busy_error = self.module["StartLockBusyError"]("completely changed text") - with mock.patch.dict( - globals_, {"acquire_start_lock": mock.Mock(side_effect=busy_error)} - ): - with self.assertRaisesRegex(self.module["ProxyError"], "stop"): - self.module["acquire_install_lock"]() + def test_install_under_running_xray_asks_to_restart_start(self): + # Роль работающего Xray играет сам тестовый процесс. + state_dir = self.root / "state/proxy-client" + state_dir.mkdir(parents=True) + (state_dir / "state.json").write_text( + json.dumps( + { + "supervisor_pid": os.getpid(), + "xray_pid": os.getpid(), + "xray_path": os.path.realpath("/proc/self/exe"), + } + ), + encoding="utf-8", + ) + self.write_destination(self.fake_xray("old", "26.3.26")) + output = self.install(replace=True) + self.assertIn("перезапустите proxy-client start", output) + self.assertIn(b"installed", self.destination.read_bytes()) - def test_path_comparison_handles_relative_entry_and_internal_bin_symlink(self): - local = self.home / ".local" - actual_bin = self.home / "private-bin" - local.mkdir() - actual_bin.mkdir() - (local / "bin").symlink_to(actual_bin, target_is_directory=True) - - self.assertEqual(0, self.install()) - self.assertTrue((actual_bin / "xray").is_file()) - - previous_cwd = Path.cwd() - try: - os.chdir(str(actual_bin)) - self.assertEqual(0, self.install(path=".")) - finally: - os.chdir(str(previous_cwd)) - - def test_install_dispatches_before_machine_config_loading(self): - self.write_destination() + def test_install_does_not_read_machine_config(self): + self.write_destination(self.fake_xray("existing")) config = self.root / "config/proxy-client" config.mkdir(parents=True) (config / "config").write_text("not an ini\n", encoding="ascii") - environment = os.environ.copy() + environment = dict(os.environ, XDG_CONFIG_HOME=str(self.root / "config")) environment.update(self.environment) - environment["PATH"] = self.environment["PATH"] + os.pathsep + os.environ["PATH"] - environment["XDG_CONFIG_HOME"] = str(self.root / "config") + environment["PATH"] += os.pathsep + os.environ["PATH"] result = subprocess.run( - [str(CLI), "install"], - text=True, - capture_output=True, - env=environment, - check=False, + [str(CLI), "install"], env=environment, text=True, capture_output=True, check=False ) self.assertEqual(0, result.returncode, result.stderr) self.assertIn("уже установлен", result.stdout) -class VlessProfileTest(unittest.TestCase): - UUID = "11111111-2222-4333-8444-555555555555" - ENCRYPTION = "mlkem768x25519plus.native.0rtt.SYNTHETIC_CLIENT_KEY" - EXTRA = ( - "%7B%22downloadSettings%22%3A%7B%22address%22%3A%22cdn.example.invalid%22" - "%2C%22port%22%3A443%2C%22network%22%3A%22xhttp%22%7D%7D" - ) - OLD_REALITY = ( - "vless://{}@example.invalid:443?type=tcp&security=reality&encryption=none" - "&flow=xtls-rprx-vision&sni=front.example.invalid&fp=chrome" - "&pbk=SYNTHETIC_PUBLIC_KEY&sid=SYNTHETIC_SHORT_ID&headerType=none#old" - ).format(UUID) - OLD_XHTTP = ( - "vless://{}@example.invalid:443?type=xhttp&security=tls&encryption=none" - "&sni=cdn.example.invalid&fp=chrome&host=cdn.example.invalid&path=%2Fp" - "&alpn=h2&mode=auto" - ).format(UUID) - # Формат Hiddify с VLESS-шифрованием: «/» перед «?» и flow поверх XHTTP. - NEW_XHTTP = ( - "vless://{}@192.0.2.1:443/?hiddify=1&fp=chrome&sni=cdn.example.invalid" - "&security=tls&alpn=h3&type=xhttp&mode=auto&path=%2Fp" - "&host=cdn.example.invalid&extra={}&flow=xtls-rprx-vision" - "&encryption={}#xhttp%20vless%20QUIC" - ).format(UUID, EXTRA, ENCRYPTION) - - def setUp(self): - self.module = runpy.run_path(str(CLI)) - - def outbound(self, url): - profile = self.module["parse_vless"](url) - config = self.module["build_xray_config"](profile, "127.0.0.1", 12334) - return config["outbounds"][0] - - def test_old_formats_still_parse(self): - reality = self.outbound(self.OLD_REALITY) - self.assertEqual("none", reality["settings"]["encryption"]) - self.assertEqual("reality", reality["streamSettings"]["security"]) - - xhttp = self.outbound(self.OLD_XHTTP) - self.assertEqual("none", xhttp["settings"]["encryption"]) - self.assertNotIn("flow", xhttp["settings"]) - self.assertEqual(["h2"], xhttp["streamSettings"]["tlsSettings"]["alpn"]) - - def test_new_hiddify_xhttp_with_vless_encryption_parses(self): - outbound = self.outbound(self.NEW_XHTTP) - settings = outbound["settings"] - stream = outbound["streamSettings"] - self.assertEqual(self.ENCRYPTION, settings["encryption"]) - self.assertEqual("xtls-rprx-vision", settings["flow"]) - self.assertEqual("192.0.2.1", settings["address"]) - self.assertEqual(["h3"], stream["tlsSettings"]["alpn"]) - self.assertEqual( - "cdn.example.invalid", - stream["xhttpSettings"]["extra"]["downloadSettings"]["address"], - ) - - def test_incompatible_profiles_are_still_rejected(self): - cases = { - "flow over XHTTP without encryption": self.OLD_XHTTP - + "&flow=xtls-rprx-vision", - "unknown encryption": self.NEW_XHTTP.replace( - "mlkem768x25519plus.", "aes-128-gcm." - ), - "non-root path": self.NEW_XHTTP.replace(":443/?", ":443/sub?"), - } - for label, url in cases.items(): - with self.subTest(label=label): - with self.assertRaises(self.module["ProxyError"]): - self.module["parse_vless"](url) - - class PortCheckTest(unittest.TestCase): - def setUp(self): - self.module = runpy.run_path(str(CLI)) - self.check = self.module["check_port_available"] - self.address = self.module["ipaddress"].ip_address("127.0.0.1") + address = MODULE["ipaddress"].ip_address("127.0.0.1") @staticmethod def listener(): @@ -1328,17 +584,14 @@ class PortCheckTest(unittest.TestCase): accepted.close() listener.close() try: - self.check(self.address, port) + MODULE["check_port_available"](self.address, port) finally: client.close() - def test_live_listener_is_still_reported_as_busy(self): - listener = self.listener() - try: - with self.assertRaisesRegex(self.module["ProxyError"], "занят"): - self.check(self.address, listener.getsockname()[1]) - finally: - listener.close() + def test_live_listener_is_reported_as_busy(self): + with self.listener() as listener: + with self.assertRaisesRegex(ProxyError, "занят"): + MODULE["check_port_available"](self.address, listener.getsockname()[1]) if __name__ == "__main__": diff --git a/tests/test_proxy_client_managed.py b/tests/test_proxy_client_managed.py index fae2ce8..b8fabe9 100644 --- a/tests/test_proxy_client_managed.py +++ b/tests/test_proxy_client_managed.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 -import fcntl +"""Команда proxy-client с управляемым Xray: argv → код выхода, вывод, файлы, процессы.""" import json import os -import shutil import signal import socket import subprocess import sys import tempfile -import time +import threading import unittest from pathlib import Path from urllib.parse import quote @@ -17,1117 +16,281 @@ from urllib.parse import quote REPO_ROOT = Path(__file__).resolve().parents[1] CLI = REPO_ROOT / "proxy-client/.local/bin/proxy-client" UUID = "11111111-2222-4333-8444-555555555555" -PBK = "PUBLIC_KEY_SENTINEL_7f8a" -SID = "SHORT_ID_SENTINEL_9b3c" PATH_SECRET = "/tenant/PATH_SENTINEL_a1b2" -PCS_A = "a" * 64 -PCS_B = "b" * 64 +PROFILE = ( + "vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none" + "&sni=tls.example&fp=firefox&alpn=h2&host=host.example&path={}#Synthetic" +).format(UUID, quote(PATH_SECRET, safe="")) +READY = "остановка: Ctrl+C или proxy-client stop" - -FAKE_XRAY_OPERAND = r'''import json -import os -import re -import signal -import socket -import sys -import threading - -log_path = os.environ["FAKE_XRAY_LOG"] -operation = os.path.basename(sys.argv[0]) - -def record(kind, **values): - values["kind"] = kind - values["argv"] = [operation] + sys.argv[1:] - values["executable"] = os.path.realpath("/proc/self/exe") - with open(log_path, "a", encoding="utf-8") as stream: - stream.write(json.dumps(values, sort_keys=True) + "\n") - -if operation == "version" and not sys.argv[1:]: - record("version") - print(os.environ["FAKE_XRAY_VERSION"]) - raise SystemExit(int(os.environ.get("FAKE_VERSION_EXIT", "0"))) - -is_test = operation == "run" and len(sys.argv) == 4 and sys.argv[1:3] == ["-test", "-c"] -is_run = operation == "run" and len(sys.argv) == 3 and sys.argv[1] == "-c" -if not (is_test or is_run): - record("bad-argv") - raise SystemExit(64) +# Поддельный Xray — симлинк на интерпретатор Python: тогда /proc/PID/exe +# совпадает с разрешённым путём к xray, как у настоящего бинарника. +# «xray version» и «xray run ...» запускают одноимённые скрипты из рабочего +# каталога теста. +FAKE_VERSION = "print('Xray 26.3.27 (fake)')\n" +FAKE_RUN = r'''import json, os, socket, sys config_path = sys.argv[-1] +if sys.argv[1] == "-test": + if os.environ.get("FAKE_TEST_FAIL"): + # Настоящий Xray может напечатать фрагменты конфига — CLI их глушит. + print(open(config_path).read()) + sys.exit(1) + sys.exit(0) -def reject_constant(value): - raise ValueError("non-JSON constant: " + value) - -with open(config_path, encoding="utf-8") as stream: - config = json.load(stream, parse_constant=reject_constant) - -if os.environ["FAKE_XRAY_VERSION"].startswith("Xray 26.3.27"): - tls = config.get("outbounds", [{}])[0].get("streamSettings", {}).get("tlsSettings", {}) - for field in ("pinnedPeerCertSha256", "verifyPeerCertByName"): - if field in tls and not isinstance(tls[field], str): - raise SystemExit(65) - if "pinnedPeerCertSha256" in tls: - hashes = tls["pinnedPeerCertSha256"].split(",") - if not hashes or any(re.fullmatch(r"[0-9a-fA-F]{64}", value) is None for value in hashes): - raise SystemExit(65) - if "verifyPeerCertByName" in tls and any( - not value for value in tls["verifyPeerCertByName"].split(",") - ): - raise SystemExit(65) - -if is_test: - record("test", config=config) - if os.environ.get("FAKE_TEST_FAIL") == "1": - sys.stderr.write(os.environ.get("FAKE_TEST_SECRET", "test failed")) - raise SystemExit(1) - raise SystemExit(0) - -record("run", config=config) -inbound = config["inbounds"][0] +inbound = json.load(open(config_path))["inbounds"][0] server = socket.socket() server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((inbound["listen"], inbound["port"])) server.listen(16) -server.settimeout(0.1) -stopping = False - -def stop(signum, _frame): - global stopping - record("signal", signum=signum) - if signum == signal.SIGTERM and os.environ.get("FAKE_IGNORE_TERM") == "1": - return - stopping = True - -signal.signal(signal.SIGTERM, stop) -signal.signal(signal.SIGINT, stop) -signal.signal(signal.SIGHUP, stop) - -def serve(client): - try: - first = client.recv(1) - if first == b"\x05": - methods = client.recv(2) - if len(methods) == 2: - client.recv(methods[1]) - client.sendall(b"\x05\x00") - request = client.recv(4) - if len(request) != 4: - return - address_type = request[3] - if address_type == 1: - client.recv(4) - elif address_type == 3: - size = client.recv(1)[0] - client.recv(size) - elif address_type == 4: - client.recv(16) - client.recv(2) - client.sendall(b"\x05\x00\x00\x01\x7f\x00\x00\x01\x00\x00") - if client.recv(4) == b"PING": - client.sendall(b"PONG") - return - request = first + client.recv(8192) - if b"ifconfig.me" in request: - record("ip-probe", request=request.decode("latin1")) - if os.environ.get("FAKE_IP_FAIL") == "1": - return - body = b"203.0.113.77\n" - else: - record("http-request", request=request.decode("latin1")) - body = b"HTTP-OK" +while True: + client, _ = server.accept() + request = client.recv(4096) + if b"ifconfig.me" in request and not os.environ.get("FAKE_IP_FAIL"): client.sendall( - b"HTTP/1.1 200 OK\r\nContent-Length: " - + str(len(body)).encode("ascii") - + b"\r\nConnection: close\r\n\r\n" - + body + b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\n" + b"203.0.113.77\n" ) - finally: - client.close() - -while not stopping: - try: - client, _address = server.accept() - except socket.timeout: - continue - threading.Thread(target=serve, args=(client,), daemon=True).start() -server.close() + client.close() ''' -class ManagedXrayCliTest(unittest.TestCase): +def free_port(): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def pid_alive(pid): + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + return True + + +class ManagedXrayTest(unittest.TestCase): def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.root = Path(self.temporary.name) - self.home = self.root / "home" - self.config_home = self.root / "config" - self.state_home = self.root / "state" - self.bin_dir = self.root / "bin" - self.home.mkdir() - self.bin_dir.mkdir() - self.log = self.root / "xray.log" - self.xray = self.bin_dir / "xray" - python_prefix = Path(sys.executable).resolve().parent.parent - python_lib = python_prefix / "lib" - if python_lib.is_dir(): - (self.root / "lib").symlink_to(python_lib, target_is_directory=True) - shutil.copy2(sys.executable, self.xray) - self.xray.chmod(0o755) - for operand in ("version", "run"): - (self.root / operand).write_text(FAKE_XRAY_OPERAND, encoding="utf-8") - self.environment = os.environ.copy() - self.environment.update( - { - "HOME": str(self.home), - "XDG_CONFIG_HOME": str(self.config_home), - "XDG_STATE_HOME": str(self.state_home), - "PATH": str(self.bin_dir) + os.pathsep + self.environment["PATH"], - "PYTHONDONTWRITEBYTECODE": "1", - "FAKE_XRAY_LOG": str(self.log), - "FAKE_XRAY_VERSION": "Xray 26.3.27 (fake)", - "PROXY_PREFLIGHT_TIMEOUT": "0.5", - "PROXY_CLIENT_STOP_TIMEOUT": "2", - } + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + bin_dir = self.root / "bin" + bin_dir.mkdir() + self.xray = bin_dir / "xray" + self.xray.symlink_to(os.path.realpath(sys.executable)) + (self.root / "version").write_text(FAKE_VERSION, encoding="utf-8") + (self.root / "run").write_text(FAKE_RUN, encoding="utf-8") + self.config_dir = self.root / "config/proxy-client" + self.state_dir = self.root / "state/proxy-client" + self.environment = dict( + os.environ, + HOME=str(self.root / "home"), + XDG_CONFIG_HOME=str(self.root / "config"), + XDG_STATE_HOME=str(self.root / "state"), + PATH=str(bin_dir) + os.pathsep + os.environ["PATH"], + PYTHONDONTWRITEBYTECODE="1", ) - self.port = self.free_port() - self.write_machine_config("xray") + self.port = free_port() + self.write_machine_config() + self.profile = self.write_profile(self.config_dir / "default.url") - def tearDown(self): - self.temporary.cleanup() - - @staticmethod - def free_port(): - with socket.socket() as listener: - listener.bind(("127.0.0.1", 0)) - return listener.getsockname()[1] - - def write_machine_config(self, proxy_type): - directory = self.config_home / "proxy-client" - directory.mkdir(parents=True, exist_ok=True) - (directory / "config").write_text( - "[proxy]\n" - "type = {}\n" - "name = Managed test\n" - "address = 127.0.0.1\n" - "port = {}\n" - "no_proxy_extra = .example.test\n".format(proxy_type, self.port), + def write_machine_config(self, address="127.0.0.1"): + self.config_dir.mkdir(parents=True, exist_ok=True) + (self.config_dir / "config").write_text( + "[proxy]\ntype = xray\nname = Managed test\naddress = {}\n" + "port = {}\nno_proxy_extra =\n".format(address, self.port), encoding="utf-8", ) - def write_profile(self, url, explicit=False): - if explicit: - path = self.root / "chosen.url" - else: - path = self.config_home / "proxy-client/default.url" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(url + "\n", encoding="utf-8") - path.chmod(0o600) + def write_profile(self, path, mode=0o600): + path.write_text(PROFILE + "\n", encoding="utf-8") + path.chmod(mode) return path - def tcp_profile(self, extra=""): - return ( - "vless://{}@edge.example:443?type=tcp&security=reality&" - "encryption=none&flow=xtls-rprx-vision&sni=front.example&fp=chrome&" - "pbk={}&sid={}&headerType=none&host=ignored.example&alpn=h2&hiddify=1{}" - "#Synthetic".format(UUID, PBK, SID, extra) - ) - - def xhttp_profile(self, extra=""): - opaque = quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe="") - modern_fields = "&pcs={}%2C{}&vcn=name-a.example%2Cname-b.example".format( - PCS_A, PCS_B - ) - return ( - "vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none&" - "sni=tls.example&fp=firefox&alpn=http%2F1.1%2Ch2&host=host.example&" - "path={}&mode=auto&extra={}{}&hiddify=1&core=xray&headerType=none{}#Hiddify" - ).format(UUID, quote(PATH_SECRET, safe=""), opaque, modern_fields, extra) - - def run_cli(self, *args, environment=None, timeout=8): - env = self.environment.copy() - if environment: - env.update(environment) + def run_cli(self, *arguments, **environment): return subprocess.run( - [str(CLI)] + list(args), + [str(CLI)] + list(arguments), cwd=str(self.root), + env=dict(self.environment, **environment), text=True, capture_output=True, - env=env, - timeout=timeout, + timeout=15, check=False, ) - def wait_for_state(self, process): - state = self.state_home / "proxy-client/state.json" - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - if state.exists(): - status = self.run_cli("status") - if status.returncode == 0: - return state - if process.poll() is not None: - stdout, stderr = process.communicate() - self.fail("start exited early: {} {}".format(stdout, stderr)) - time.sleep(0.03) - self.fail("managed state did not become ready") - - def log_entries(self): - if not self.log.exists(): - return [] - return [json.loads(line) for line in self.log.read_text().splitlines()] - - def managed_state_path(self): - return self.state_home / "proxy-client/state.json" - - def read_state(self): - return json.loads(self.managed_state_path().read_text(encoding="utf-8")) - - def write_state(self, state): - self.managed_state_path().write_text( - json.dumps(state) + "\n", encoding="utf-8" - ) - self.managed_state_path().chmod(0o600) - - def start(self, profile=None): - args = [str(CLI), "start"] - if profile is not None: - args.extend(["-f", str(profile)]) - process = subprocess.Popen( - args, - cwd=str(self.root), - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=self.environment, - ) - self.wait_for_state(process) - return process - - def stop_and_collect(self, process): - self.write_machine_config("external") - stopped = self.run_cli("stop") - stdout, stderr = process.communicate(timeout=4) - self.assertEqual(0, stopped.returncode, stopped.stderr) - self.assertEqual(0, process.returncode, stderr) - return stdout, stderr - - def assert_no_secrets(self, text, profile): - for secret in (profile, UUID, PBK, SID, PATH_SECRET): - self.assertNotIn(secret, text) - - def test_complete_managed_lifecycle_for_both_profile_locations(self): - profiles = ((self.xhttp_profile(), False), (self.tcp_profile(), True)) - for profile_text, explicit in profiles: - with self.subTest(explicit=explicit): - profile = self.write_profile(profile_text, explicit=explicit) - process = self.start(profile if explicit else None) - - status = self.run_cli("status") - env = self.run_cli("env") - child = self.run_cli( - "run", - "--", - sys.executable, - "-c", - "import os; print(os.environ['HTTP_PROXY'])", - ) - self.assertEqual(0, status.returncode, status.stderr) - self.assertIn("управляемый Xray", status.stdout) - self.assertEqual(0, env.returncode, env.stderr) - self.assertIn("HTTP_PROXY=http://127.0.0.1:{}".format(self.port), env.stdout) - self.assertEqual(0, child.returncode, child.stderr) - version_summary = process.stdout.readline() - self.assertIn(str(self.xray.resolve()), version_summary) - self.assertIn("26.3.27", version_summary) - - with socket.create_connection(("127.0.0.1", self.port), timeout=1) as http: - http.sendall(b"GET http://example.test/ HTTP/1.1\r\nHost: example.test\r\n\r\n") - self.assertIn(b"HTTP-OK", http.recv(4096)) - with socket.create_connection(("127.0.0.1", self.port), timeout=1) as socks: - socks.sendall(b"\x05\x01\x00") - self.assertEqual(b"\x05\x00", socks.recv(2)) - socks.sendall(b"\x05\x01\x00\x03\x0cexample.test\x00\x50") - self.assertEqual(10, len(socks.recv(10))) - socks.sendall(b"PING") - self.assertEqual(b"PONG", socks.recv(4)) - - stdout, stderr = self.stop_and_collect(process) - entries = self.log_entries() - kinds = [entry["kind"] for entry in entries] - self.assertLess(kinds.index("version"), kinds.index("test")) - self.assertLess(kinds.index("test"), kinds.index("run")) - self.assertIn("ip-probe", kinds) - for entry in entries: - self.assertEqual(str(self.xray.resolve()), entry["executable"]) - self.assertFalse((self.state_home / "proxy-client/state.json").exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - self.assert_no_secrets(stdout + stderr + status.stdout + env.stdout, profile_text) - self.log.unlink() - self.port = self.free_port() - self.write_machine_config("xray") - - def test_generated_xhttp_config_preserves_fields_and_redacts_path(self): - profile_text = self.xhttp_profile() - self.write_profile(profile_text) - process = self.start() - stdout, stderr = self.stop_and_collect(process) - config = next(entry["config"] for entry in self.log_entries() if entry["kind"] == "test") - outbound = config["outbounds"][0] - stream = outbound["streamSettings"] - self.assertEqual("xhttp", stream["network"]) - self.assertEqual( - {"host": "host.example", "path": PATH_SECRET, "mode": "auto", "extra": {"downloadSettings": {"address": "127.0.0.1"}}}, - stream["xhttpSettings"], - ) - self.assertEqual( - PCS_A + "," + PCS_B, - stream["tlsSettings"]["pinnedPeerCertSha256"], - ) - self.assertEqual( - "name-a.example,name-b.example", - stream["tlsSettings"]["verifyPeerCertByName"], - ) - self.assertEqual(["http/1.1", "h2"], stream["tlsSettings"]["alpn"]) - self.assertEqual("none", config["log"]["access"]) - self.assertIn("длина пути: {}".format(len(PATH_SECRET)), stdout) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_reality_mapping_state_shape_modes_and_child_argv_are_closed(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state_path = self.state_home / "proxy-client/state.json" - config_path = self.state_home / "proxy-client/client.json" - lock_path = self.state_home / "proxy-client/lock" - state = json.loads(state_path.read_text(encoding="utf-8")) - config = json.loads(config_path.read_text(encoding="utf-8")) - self.assertEqual(0o600, state_path.stat().st_mode & 0o777) - self.assertEqual(0o600, config_path.stat().st_mode & 0o777) - self.assertEqual(0o600, lock_path.stat().st_mode & 0o777) - self.assertEqual("", lock_path.read_text(encoding="ascii")) - self.assertEqual(0o700, state_path.parent.stat().st_mode & 0o777) - self.assertEqual(str(self.xray.resolve()), state["xray_path"]) - self.assertEqual("127.0.0.1", state["address"]) - self.assertEqual(self.port, state["port"]) - self.assertEqual( - { - "supervisor_pid", - "xray_pid", - "xray_path", - "config_path", - "address", - "port", - "supervisor_path", - "supervisor_starttime", - "xray_starttime", - "boot_id", - }, - set(state), - ) - self.assertEqual(str(CLI.resolve()), state["supervisor_path"]) - self.assertIsInstance(state["supervisor_starttime"], int) - self.assertIsInstance(state["xray_starttime"], int) - self.assertEqual( - Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip(), - state["boot_id"], - ) - state_text = state_path.read_text(encoding="utf-8") - self.assert_no_secrets(state_text, profile_text) - stream = config["outbounds"][0]["streamSettings"] - inbound = config["inbounds"][0] - self.assertEqual("127.0.0.1", inbound["listen"]) - self.assertEqual(self.port, inbound["port"]) - self.assertEqual("socks", inbound["protocol"]) - self.assertEqual("raw", stream["network"]) - self.assertEqual("reality", stream["security"]) - self.assertEqual( - { - "serverName": "front.example", - "fingerprint": "chrome", - "password": PBK, - "shortId": SID, - }, - stream["realitySettings"], - ) - self.stop_and_collect(process) - for entry in self.log_entries(): - argv_text = "\0".join(entry["argv"]) - self.assert_no_secrets(argv_text, profile_text) - - def test_held_lock_rejects_start_without_changing_managed_files(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - state_dir = self.state_home / "proxy-client" - state_dir.mkdir(parents=True, mode=0o700) - state_path = state_dir / "state.json" - config_path = state_dir / "client.json" - state_path.write_text("state sentinel\n", encoding="ascii") - config_path.write_text("config sentinel\n", encoding="ascii") - lock_path = state_dir / "lock" - with lock_path.open("w", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - result = self.run_cli("start") - - self.assertNotEqual(0, result.returncode) - self.assertIn("блокиров", result.stderr) - self.assertEqual("state sentinel\n", state_path.read_text(encoding="ascii")) - self.assertEqual("config sentinel\n", config_path.read_text(encoding="ascii")) - self.assertEqual([], self.log_entries()) - self.assert_no_secrets( - result.stdout + result.stderr + lock_path.read_text(encoding="ascii"), - profile_text, - ) - - def test_live_managed_instance_rejects_install_replace_without_changes(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state_before = self.managed_state_path().read_bytes() - xray_before = self.xray.read_bytes() - - result = self.run_cli("install", "--replace") - - self.assertNotEqual(0, result.returncode) - self.assertIn("сначала", result.stderr) - self.assertIn("stop", result.stderr) - self.assertEqual(state_before, self.managed_state_path().read_bytes()) - self.assertEqual(xray_before, self.xray.read_bytes()) - self.assertFalse((self.home / ".local/bin/xray").exists()) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.stop_and_collect(process) - - def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self): - state_dir = self.state_home / "proxy-client" - state_dir.mkdir(parents=True, mode=0o700) - state = { - "supervisor_pid": 999999998, - "xray_pid": 999999999, - "xray_path": str(self.xray.resolve()), - "config_path": str(state_dir / "client.json"), - "address": "127.0.0.1", - "port": self.port, - "supervisor_path": str(CLI.resolve()), - "supervisor_starttime": 1, - "xray_starttime": 1, - "boot_id": Path("/proc/sys/kernel/random/boot_id") - .read_text(encoding="ascii") - .strip(), - } - self.write_state(state) - config_path = state_dir / "client.json" - config_path.write_text("stale sentinel\n", encoding="ascii") - lock_path = state_dir / "lock" - with lock_path.open("w", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - status = self.run_cli("status") - - self.assertNotEqual(0, status.returncode) - self.assertIn("блокиров", status.stderr) - self.assertTrue(self.managed_state_path().exists()) - self.assertEqual("stale sentinel\n", config_path.read_text(encoding="ascii")) - - def test_reused_pid_starttime_is_not_signalled_and_state_is_preserved(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - original = self.read_state() - changed = dict(original) - changed["xray_starttime"] += 1 - self.write_state(changed) - marker = self.root / "reused-pid-child-ran" - - for command in ( - ("status",), - ("env",), - ("run", "--", "touch", str(marker)), - ("stop",), - ): - with self.subTest(command=command): - result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) - self.assertIn("вручную", result.stderr) - self.assertTrue(self.managed_state_path().exists()) - self.assertFalse(marker.exists()) - self.assertTrue(process.poll() is None) - - self.write_state(original) - stdout, stderr = self.stop_and_collect(process) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_wrong_xray_executable_is_not_signalled_and_state_is_preserved(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - original = self.read_state() - changed = dict(original) - changed["xray_path"] = str(Path("/bin/false").resolve()) - self.write_state(changed) - - stopped = self.run_cli("stop") - self.assertNotEqual(0, stopped.returncode) - self.assertIn("вручную", stopped.stderr) - self.assertTrue(self.managed_state_path().exists()) - self.assertIsNone(process.poll()) - - self.write_state(original) - stdout, stderr = self.stop_and_collect(process) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_boot_id_mismatch_cleans_stale_files_without_signalling_processes(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state = self.read_state() - state["boot_id"] = "00000000-0000-0000-0000-000000000000" - self.write_state(state) - os.kill(state["supervisor_pid"], signal.SIGKILL) - process.wait(timeout=2) - - status = self.run_cli("status") - self.assertNotEqual(0, status.returncode) - self.assertIn("устаревшее", status.stderr) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - self.assertTrue(Path("/proc/{}".format(state["xray_pid"])).exists()) - - os.kill(state["xray_pid"], signal.SIGKILL) - stdout, stderr = process.communicate(timeout=4) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_dead_pids_allow_stale_state_and_config_cleanup(self): - state_dir = self.state_home / "proxy-client" - state_dir.mkdir(parents=True, mode=0o700) - state = { - "supervisor_pid": 999999998, - "xray_pid": 999999999, - "xray_path": str(self.xray.resolve()), - "config_path": str(state_dir / "client.json"), - "address": "127.0.0.1", - "port": self.port, - "supervisor_path": str(CLI.resolve()), - "supervisor_starttime": 1, - "xray_starttime": 1, - "boot_id": Path("/proc/sys/kernel/random/boot_id") - .read_text(encoding="ascii") - .strip(), - } - self.write_state(state) - (state_dir / "client.json").write_text("stale\n", encoding="ascii") - - status = self.run_cli("status") - self.assertNotEqual(0, status.returncode) - self.assertIn("устаревшее", status.stderr) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((state_dir / "client.json").exists()) - - def test_sigkill_supervisor_stop_terminates_confirmed_orphan_and_cleans(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state = self.read_state() - run_count = len( - [entry for entry in self.log_entries() if entry["kind"] == "run"] - ) - alternate_profile = self.write_profile(self.xhttp_profile(), explicit=True) - repeated = self.run_cli("start") - switched = self.run_cli("start", "-f", str(alternate_profile)) - for result in (repeated, switched): - self.assertNotEqual(0, result.returncode) - self.assertIn("блокиров", result.stderr) - self.assertEqual( - run_count, - len([entry for entry in self.log_entries() if entry["kind"] == "run"]), - ) - - lock_path = self.state_home / "proxy-client/lock" - with lock_path.open("r+", encoding="ascii") as lock: - with self.assertRaises(OSError): - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - - os.kill(state["supervisor_pid"], signal.SIGKILL) - process.wait(timeout=2) - with lock_path.open("r+", encoding="ascii") as lock: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - fcntl.flock(lock, fcntl.LOCK_UN) - - status = self.run_cli("status") - env = self.run_cli("env") - marker = self.root / "orphan-child-ran" - run = self.run_cli("run", "--", "touch", str(marker)) - for result in (status, env, run): - self.assertNotEqual(0, result.returncode) - self.assertIn("сирот", result.stderr) - self.assertFalse(marker.exists()) - - stopped = self.run_cli("stop") - self.assertEqual(0, stopped.returncode, stopped.stderr) - self.assertIn("сирот", stopped.stdout) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - stdout, stderr = process.communicate(timeout=2) - self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text) - - self.port = self.free_port() - self.write_machine_config("xray") - replacement = self.start() - replacement_stdout, replacement_stderr = self.stop_and_collect(replacement) - self.assert_no_secrets( - replacement_stdout + replacement_stderr, profile_text - ) - - def test_unverifiable_live_child_preserves_state_and_config(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - self.environment["PROXY_CLIENT_STOP_TIMEOUT"] = "0.2" - process = self.start() - state = self.read_state() - self.xray.unlink() - - os.kill(state["supervisor_pid"], signal.SIGTERM) - process.wait(timeout=3) - state_exists = self.managed_state_path().exists() - config_path = self.state_home / "proxy-client/client.json" - config_exists = config_path.exists() - child_alive = Path("/proc/{}".format(state["xray_pid"])).exists() - if child_alive: - os.kill(state["xray_pid"], signal.SIGKILL) - deadline = time.monotonic() + 2 - while ( - Path("/proc/{}".format(state["xray_pid"])).exists() - and time.monotonic() < deadline - ): - time.sleep(0.02) - stdout, stderr = process.communicate(timeout=2) - - self.assertTrue(child_alive) - self.assertTrue(state_exists) - self.assertTrue(config_exists) - self.assertNotEqual(0, process.returncode) - self.assertIn("вручную", stderr) - self.assert_no_secrets(stdout + stderr, profile_text) - - def test_orphan_ignoring_sigterm_is_rechecked_killed_and_cleaned(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - self.environment["FAKE_IGNORE_TERM"] = "1" - process = self.start() - state = self.read_state() - os.kill(state["supervisor_pid"], signal.SIGKILL) - process.wait(timeout=2) - - stopped = self.run_cli( - "stop", environment={"PROXY_CLIENT_STOP_TIMEOUT": "0.2"} - ) - self.assertEqual(0, stopped.returncode, stopped.stderr) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - signals = [ - entry["signum"] - for entry in self.log_entries() - if entry["kind"] == "signal" - ] - self.assertEqual([signal.SIGTERM], signals) - stdout, stderr = process.communicate(timeout=2) - self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text) - - def test_occupied_port_fails_without_touching_listener_or_leaving_generated_files(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - with socket.socket() as listener: - listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - listener.bind(("127.0.0.1", self.port)) - listener.listen(1) - result = self.run_cli("start") - with socket.create_connection(("127.0.0.1", self.port), timeout=1): - pass - - self.assertNotEqual(0, result.returncode) - self.assertIn("порт {}".format(self.port), result.stderr) - self.assertIn("занят", result.stderr) - self.assertEqual([], self.log_entries()) - self.assertFalse(self.managed_state_path().exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - - def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self): - invalid_versions = ( - "Xray 25.12.8", - "Xray 26.3.270", - "junk Xray 26.3.27", - "Xray 26.5.9", - "Xray", - "", - ) - for version_line in invalid_versions: - with self.subTest(version=version_line): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - result = self.run_cli("start", environment={"FAKE_XRAY_VERSION": version_line}) - self.assertNotEqual(0, result.returncode) - self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()]) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.log.unlink() - - profile_text = self.tcp_profile() - self.write_profile(profile_text) - result = self.run_cli("start", environment={"FAKE_VERSION_EXIT": "1"}) - self.assertNotEqual(0, result.returncode) - self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()]) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.log.unlink() - - invalid_profiles = ( - self.tcp_profile("&mystery=value"), - self.tcp_profile("&sni=duplicate.example"), - self.tcp_profile("&headerType=http"), - self.tcp_profile("&flow=other"), - self.tcp_profile("&sni=bad%0Aline"), - self.tcp_profile().replace("sni=front.example", "sni=bad%FF"), - self.tcp_profile().replace("@edge.example:443?", "@edge.example:443/path?"), - self.tcp_profile().replace("type=tcp", "type=grpc"), - self.tcp_profile().replace("&pbk={}".format(PBK), ""), - self.xhttp_profile("&allowInsecure=True"), - self.xhttp_profile("&allowInsecure=true&insecure=false"), - self.xhttp_profile().replace("security=tls", "security=reality"), - ) - for profile_text in invalid_profiles: - with self.subTest(profile=profile_text[-50:]): - self.write_profile(profile_text) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()]) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.log.unlink() - - def test_exact_boolean_version_alpn_and_extra_branches(self): - accepted_alpn = ("http/1.1", "h2", "h3", "http/1.1,h2", "h2,h3") - for value in accepted_alpn: - with self.subTest(alpn=value): - profile_text = self.xhttp_profile().replace( - "alpn=http%2F1.1%2Ch2", "alpn=" + quote(value, safe="") - ) - self.write_profile(profile_text) - process = self.start() - self.stop_and_collect(process) - config = next( - entry["config"] - for entry in self.log_entries() - if entry["kind"] == "test" - ) - self.assertEqual( - value.split(","), - config["outbounds"][0]["streamSettings"]["tlsSettings"]["alpn"], - ) - self.log.unlink() - self.port = self.free_port() - self.write_machine_config("xray") - - cases = ( - ("", False), - ("&allowInsecure=false", False), - ("&allowInsecure=false&insecure=false", False), - ("&insecure=true", True), - ("&allowInsecure=true&insecure=true", True), - ) - for fields, warns in cases: - with self.subTest(fields=fields): - profile_text = self.xhttp_profile(fields) - self.write_profile(profile_text) - process = self.start() - stdout, stderr = self.stop_and_collect(process) - config = next( - entry["config"] - for entry in self.log_entries() - if entry["kind"] == "test" - ) - tls = config["outbounds"][0]["streamSettings"]["tlsSettings"] - self.assertNotIn("allowInsecure", tls) - self.assertEqual(warns, "Предупреждение" in stderr) - self.assert_no_secrets(stdout + stderr, profile_text) - self.log.unlink() - self.port = self.free_port() - self.write_machine_config("xray") - - rejected = ( - self.xhttp_profile("&allowInsecure=TRUE"), - self.xhttp_profile("&allowInsecure=1"), - self.xhttp_profile("&allowInsecure=true&insecure=false"), - self.xhttp_profile().replace( - quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""), - quote('{"same":1,"same":2}', safe=""), - ), - self.xhttp_profile().replace("alpn=http%2F1.1%2Ch2", "alpn=h4"), - self.xhttp_profile().replace( - quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""), - quote('{"threshold":NaN}', safe=""), - ), - self.xhttp_profile().replace( - quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""), - quote('{"threshold":Infinity}', safe=""), - ), - self.xhttp_profile().replace( - quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe=""), - quote('{"threshold":-Infinity}', safe=""), - ), - ) - self.environment["FAKE_XRAY_VERSION"] = "Xray 26.3.27 (fake)" - for profile_text in rejected: - with self.subTest(rejected=profile_text[-45:]): - self.write_profile(profile_text) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()]) - if "threshold" in profile_text: - self.assertIn("extra", result.stderr) - self.assertNotIn("run -test", result.stderr) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.log.unlink() - - def test_fake_run_test_rejects_non_json_constants(self): - for constant in ("NaN", "Infinity", "-Infinity"): - with self.subTest(constant=constant): - config = self.root / "invalid-constant.json" - config.write_text('{"value":' + constant + '}\n', encoding="utf-8") - result = subprocess.run( - [str(self.xray), "run", "-test", "-c", str(config)], - cwd=str(self.root), - text=True, - capture_output=True, - env=self.environment, - check=False, - ) - self.assertNotEqual(0, result.returncode) - - def test_failed_ip_diagnostic_is_proxy_observed_warning_and_non_fatal(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - # The fake inherited the flag at launch only; restart with the failure flag. - self.write_machine_config("external") - self.run_cli("stop") - process.communicate(timeout=4) - self.log.unlink() - self.port = self.free_port() - self.write_machine_config("xray") + def start(self, **environment): + """Запускает start и ждёт строку готовности; возвращает процесс.""" process = subprocess.Popen( [str(CLI), "start"], cwd=str(self.root), + env=dict(self.environment, **environment), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env=dict(self.environment, FAKE_IP_FAIL="1"), ) - self.wait_for_state(process) - self.assertIsNone(process.poll()) - stdout, stderr = self.stop_and_collect(process) - self.assertIn("Предупреждение", stderr) - self.assertIn("ip-probe", [entry["kind"] for entry in self.log_entries()]) - self.assert_no_secrets(stdout + stderr, profile_text) + self.addCleanup(self.kill_leftovers, process) + watchdog = threading.Timer(10, process.kill) + watchdog.start() + self.start_output = "" + try: + for line in process.stdout: + self.start_output += line + if READY in line: + return process + finally: + watchdog.cancel() + self.fail("start не дошёл до готовности: " + process.communicate()[1]) - def test_immediate_stop_after_first_ready_status_returns_foreground_start(self): - for attempt in range(3): - with self.subTest(attempt=attempt): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - stdout, stderr = self.stop_and_collect(process) - self.assertFalse((self.state_home / "proxy-client/state.json").exists()) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) - self.assert_no_secrets(stdout + stderr, profile_text) - self.log.unlink() - self.port = self.free_port() - self.write_machine_config("xray") + def kill_leftovers(self, process): + state = self.state_dir / "state.json" + if state.exists(): + pid = json.loads(state.read_text(encoding="utf-8"))["xray_pid"] + if pid_alive(pid): + os.kill(pid, signal.SIGKILL) + if process.poll() is None: + process.kill() + process.communicate() - def test_profile_permissions_follow_symlink_and_test_failure_does_not_leak(self): - profile_text = self.xhttp_profile() - target = self.root / "target.url" - target.write_text(profile_text + "\n", encoding="utf-8") - target.chmod(0o640) - link = self.root / "profile.url" - link.symlink_to(target) - result = self.run_cli("start", "-f", str(link)) - self.assertNotEqual(0, result.returncode) - self.assertIn("chmod 600", result.stderr) - self.assertEqual([], self.log_entries()) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) + def finish(self, process): + stdout, stderr = process.communicate(timeout=10) + return self.start_output + stdout, stderr - target.chmod(0o600) - target.write_text(profile_text + "\n" + self.tcp_profile() + "\n", encoding="utf-8") - result = self.run_cli("start", "-f", str(link)) - self.assertNotEqual(0, result.returncode) - self.assertIn("ровно одну", result.stderr) - self.assertEqual([], self.log_entries()) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) + def xray_pid(self): + return json.loads((self.state_dir / "state.json").read_text(encoding="utf-8"))[ + "xray_pid" + ] - target.write_text(profile_text + "\n", encoding="utf-8") - target.chmod(0o600) - result = self.run_cli( - "start", - "-f", - str(link), - environment={"FAKE_TEST_FAIL": "1", "FAKE_TEST_SECRET": PATH_SECRET}, - ) - self.assertNotEqual(0, result.returncode) - self.assertEqual(["version", "test"], [entry["kind"] for entry in self.log_entries()]) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) - self.assertFalse((self.state_home / "proxy-client/client.json").exists()) + def assert_no_state_files(self): + self.assertFalse((self.state_dir / "state.json").exists()) + self.assertFalse((self.state_dir / "client.json").exists()) - def test_xray_start_requires_loopback_before_selecting_binary(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - config_path = self.config_home / "proxy-client/config" - config_path.write_text( - config_path.read_text(encoding="utf-8").replace( - "address = 127.0.0.1", "address = 192.0.2.10" - ), - encoding="utf-8", - ) - result = self.run_cli("start") - self.assertNotEqual(0, result.returncode) - self.assertIn("loopback", result.stderr) - self.assertEqual([], self.log_entries()) - self.assert_no_secrets(result.stdout + result.stderr, profile_text) + def assert_no_secrets(self, text): + for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe="")): + self.assertNotIn(secret, text) - def test_foreign_state_and_listener_fail_closed_and_are_not_signalled(self): - listener = socket.socket() - listener.bind(("127.0.0.1", self.port)) - listener.listen(1) - state_dir = self.state_home / "proxy-client" - state_dir.mkdir(parents=True) - state = { - "supervisor_pid": os.getpid(), - "xray_pid": os.getpid(), - "xray_path": str(self.xray.resolve()), - "port": self.port, - "address": "127.0.0.1", - "config_path": str(state_dir / "client.json"), - } - (state_dir / "state.json").write_text(json.dumps(state), encoding="utf-8") - (state_dir / "state.json").chmod(0o600) - marker = self.root / "child-ran" - for command in (("status",), ("env",), ("run", "--", "touch", str(marker)), ("stop",)): - with self.subTest(command=command): - result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) - self.assertIn("не подтвержден", result.stderr) - self.assertFalse(marker.exists()) - listener.close() - - def test_substituted_machine_port_fails_closed_without_touching_foreign_listener(self): - profile_text = self.tcp_profile() - self.write_profile(profile_text) + def test_full_lifecycle(self): process = self.start() - managed_port = self.port - foreign_port = self.free_port() - listener = socket.socket() - listener.bind(("127.0.0.1", foreign_port)) - listener.listen(1) - config_path = self.config_home / "proxy-client/config" - config_path.write_text( - config_path.read_text(encoding="utf-8").replace( - "port = {}".format(managed_port), "port = {}".format(foreign_port) - ), - encoding="utf-8", + xray_pid = self.xray_pid() + self.assertEqual(0o700, self.state_dir.stat().st_mode & 0o777) + for name in ("state.json", "client.json"): + self.assertEqual(0o600, (self.state_dir / name).stat().st_mode & 0o777) + + status = self.run_cli("status") + env = self.run_cli("env") + child = self.run_cli( + "run", "--", sys.executable, "-c", "import os; print(os.environ['ALL_PROXY'])" ) - marker = self.root / "substituted-child-ran" - for command in ( - ("status",), - ("env",), - ("run", "--", "touch", str(marker)), - ): - with self.subTest(command=command): - result = self.run_cli(*command) - self.assertNotEqual(0, result.returncode) - self.assertIn("не совпадают", result.stderr) - self.assertFalse(marker.exists()) + second = self.run_cli("start") + self.assertEqual(0, status.returncode, status.stderr) + self.assertIn("управляемый Xray работает", status.stdout) + self.assertEqual(0, env.returncode, env.stderr) + self.assertIn("HTTP_PROXY=http://127.0.0.1:{}\n".format(self.port), env.stdout) + self.assertEqual("socks5://127.0.0.1:{}\n".format(self.port), child.stdout) + self.assertEqual(1, second.returncode) + self.assertIn("уже запущен", second.stderr) + stopped = self.run_cli("stop") - stdout, stderr = process.communicate(timeout=4) + stdout, stderr = self.finish(process) self.assertEqual(0, stopped.returncode, stopped.stderr) self.assertEqual(0, process.returncode, stderr) - self.assert_no_secrets(stdout + stderr, profile_text) - listener.settimeout(0.2) - with socket.create_connection(("127.0.0.1", foreign_port), timeout=1): - pass - listener.close() + self.assertFalse(pid_alive(xray_pid)) + self.assert_no_state_files() + self.assertIn("Внешний IP через прокси: 203.0.113.77", stdout) + self.assertIn("длина пути: {}".format(len(PATH_SECRET)), stdout) + self.assert_no_secrets(stdout + stderr + status.stdout + env.stdout) - def test_supervisor_forwards_original_signal_to_child(self): - for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): - with self.subTest(signum=signum): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = self.start() - state = json.loads( - (self.state_home / "proxy-client/state.json").read_text( - encoding="utf-8" - ) - ) - os.kill(state["supervisor_pid"], signum) - stdout, stderr = process.communicate(timeout=4) - signals = [ - entry["signum"] - for entry in self.log_entries() - if entry["kind"] == "signal" - ] - self.assertEqual([signum], signals) - self.assertEqual(0, process.returncode, stderr) - self.assert_no_secrets(stdout + stderr, profile_text) - self.log.unlink() - self.port = self.free_port() - self.write_machine_config("xray") + def test_ctrl_c_stops_xray_and_removes_files(self): + process = self.start() + xray_pid = self.xray_pid() + process.send_signal(signal.SIGINT) + self.finish(process) + self.assertEqual(0, process.returncode) + self.assertFalse(pid_alive(xray_pid)) + self.assert_no_state_files() - def test_absolute_relative_and_path_symlink_cli_invocations_are_manageable(self): - proxy_bin = self.root / "proxy-bin" - proxy_bin.mkdir() - (proxy_bin / "proxy-client").symlink_to(CLI) - invocations = ( - ([str(CLI)], self.root, self.environment), - ([os.path.relpath(str(CLI), str(self.root))], self.root, self.environment), - ( - ["proxy-client"], - self.root, - dict( - self.environment, - PATH=str(proxy_bin) - + os.pathsep - + str(self.bin_dir) - + os.pathsep - + os.environ["PATH"], - ), + def test_stop_finishes_xray_left_after_killed_supervisor(self): + process = self.start() + xray_pid = self.xray_pid() + process.kill() + process.wait() + + status = self.run_cli("status") + self.assertEqual(1, status.returncode) + self.assertIn("proxy-client stop", status.stderr) + restarted = self.run_cli("start") + self.assertEqual(1, restarted.returncode) + self.assertIn("proxy-client stop", restarted.stderr) + + stopped = self.run_cli("stop") + self.assertEqual(0, stopped.returncode, stopped.stderr) + self.assertFalse(pid_alive(xray_pid)) + self.assert_no_state_files() + + def test_stale_files_after_processes_died_are_removed(self): + self.state_dir.mkdir(parents=True) + (self.state_dir / "state.json").write_text( + json.dumps( + {"supervisor_pid": 999999998, "xray_pid": 999999999, "xray_path": "/x"} ), + encoding="utf-8", ) - for command, cwd, environment in invocations: - with self.subTest(command=command): - profile_text = self.tcp_profile() - self.write_profile(profile_text) - process = subprocess.Popen( - command + ["start"], - cwd=str(cwd), - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=environment, - ) - state_path = self.state_home / "proxy-client/state.json" - deadline = time.monotonic() + 3 - while time.monotonic() < deadline and not state_path.exists(): - if process.poll() is not None: - break - time.sleep(0.02) - status = self.run_cli("status") - if status.returncode == 0: - stopped = self.run_cli("stop") - else: - stopped = None - if process.poll() is None: - os.kill(process.pid, signal.SIGTERM) - stdout, stderr = process.communicate(timeout=4) - self.assertEqual(0, status.returncode, status.stderr) - self.assertIsNotNone(stopped) - self.assertEqual(0, stopped.returncode, stopped.stderr) - self.assertEqual(0, process.returncode, stderr) - self.assert_no_secrets(stdout + stderr, profile_text) - self.log.unlink() - self.port = self.free_port() - self.write_machine_config("xray") + (self.state_dir / "client.json").write_text("{}", encoding="utf-8") + + result = self.run_cli("status") + self.assertEqual(1, result.returncode) + self.assertIn("не запущен", result.stderr) + self.assert_no_state_files() + + def test_broken_state_file_is_named_in_error(self): + self.state_dir.mkdir(parents=True) + state = self.state_dir / "state.json" + state.write_text("not json", encoding="utf-8") + result = self.run_cli("stop") + self.assertEqual(1, result.returncode) + self.assertIn("rm {}".format(state), result.stderr) + + def test_busy_port_is_reported(self): + with socket.socket() as listener: + listener.bind(("127.0.0.1", self.port)) + listener.listen() + result = self.run_cli("start") + self.assertEqual(1, result.returncode) + self.assertIn("порт {} занят".format(self.port), result.stderr) + self.assert_no_state_files() + + def test_non_loopback_address_is_refused(self): + self.write_machine_config(address="192.0.2.10") + result = self.run_cli("start") + self.assertEqual(1, result.returncode) + self.assertIn("loopback", result.stderr) + + def test_open_profile_names_target_file_and_gives_chmod_command(self): + target = self.write_profile(self.root / "real profile.url", mode=0o640) + link = self.root / "link.url" + link.symlink_to(target) + result = self.run_cli("start", "-f", str(link)) + self.assertEqual(1, result.returncode) + self.assertIn( + "файл профиля {} доступен другим пользователям; " + "выполните: chmod 600 '{}'".format(target, target), + result.stderr, + ) + self.assert_no_secrets(result.stdout + result.stderr) + + def test_failed_config_test_leaves_no_files_and_no_secrets(self): + result = self.run_cli("start", FAKE_TEST_FAIL="1") + self.assertEqual(1, result.returncode) + self.assertIn("run -test", result.stderr) + self.assert_no_state_files() + self.assert_no_secrets(result.stdout + result.stderr) + + def test_failed_ip_probe_is_only_a_warning(self): + process = self.start(FAKE_IP_FAIL="1") + self.assertIsNone(process.poll()) + process.send_signal(signal.SIGINT) + _stdout, stderr = self.finish(process) + self.assertEqual(0, process.returncode) + self.assertIn("Предупреждение: внешний IP", stderr) if __name__ == "__main__": From e709c585fab353f3831a2240abdb95b3762e2824 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 09:36:56 -0400 Subject: [PATCH 6/9] =?UTF-8?q?docs(proxy-client):=20=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D1=86=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D1=83=D0=BF=D1=80=D0=BE=D1=89=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=B4,=20=D1=81=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=D0=B7=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Спецификация 603 → 220 строк: без описаний снятых защит и без пересказа кода и README; остались задача, границы, решения с причинами и риски. В README раздел «Изменение кода» ведёт к спецификации и тестам. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specs/2026-07-15-proxy-client-design.md | 651 ++++--------------- proxy-client/README.md | 23 +- 2 files changed, 149 insertions(+), 525 deletions(-) diff --git a/docs/specs/2026-07-15-proxy-client-design.md b/docs/specs/2026-07-15-proxy-client-design.md index 6a06f3c..4f4c823 100644 --- a/docs/specs/2026-07-15-proxy-client-design.md +++ b/docs/specs/2026-07-15-proxy-client-design.md @@ -4,6 +4,10 @@ Статус: реализовано; 2026-09-28 упрощено по принципу ниже +Точное поведение, форматы, сообщения и команды описаны в коде, +`proxy-client --help` и [`proxy-client/README.md`](../../proxy-client/README.md). +Здесь — то, чего по коду не видно: задача, границы, выбор и его причины, риски. + ## Принцип: личный инструмент `proxy-client` пользуются владелец и несколько друзей на своих машинах. Код @@ -13,8 +17,9 @@ - секреты профиля не попадают в вывод, служебные файлы закрыты правами 600/700; - локальный порт Xray слушает только loopback; - чужие файлы не перезаписываются; -- сигнал уходит только процессу, у которого `/proc/PID/exe` совпадает с - запущенным Xray. +- сигнал уходит только процессам из `state.json`: Xray — если его + `/proc/PID/exe` совпадает с запущенным файлом, супервизору — если Xray + подтверждён и в командной строке супервизора есть `proxy-client`. Пользователь и его собственные файлы — не противник. Сообщение об ошибке называет файл полным путём и подсказывает готовую команду. @@ -34,570 +39,182 @@ На удалённых Linux-серверах нет GUI-клиента Hiddify. Перед запуском Claude Code или Codex приходится вручную запускать Xray и выставлять proxy-переменные. -Переменные легко забыть, после чего агент стартует без прокси. +Переменные легко забыть, и агент стартует без прокси. Нужны привычные команды `pclaude` и `pcodex`, которые не запускают агента, -если явно настроенный прокси недоступен. Решение должно работать как часть этого -репозитория и отдельно от остальных dotfiles. +если явно настроенный прокси недоступен. Решение работает и в составе этого +репозитория, и отдельно от остальных dotfiles. ## Контекст -Существующий дизайн +Прежний дизайн [`pclaude` / `pcodex`](../superpowers/specs/2026-05-19-proxy-cli-launchers-design.md) -задаёт важный контракт: proxy-переменные видны только дочернему процессу, -аргументы и код возврата сохраняются, а недоступный прокси приводит к отказу -до запуска агента. +задаёт договор: proxy-переменные видны только дочернему процессу, аргументы и +код возврата сохраняются, недоступный прокси — отказ до запуска агента. Эта +спецификация заменяет его Linux-часть; Windows-часть не меняется. -Эта спецификация заменяет Linux-часть прежнего дизайна в вопросах расположения -функций, источника настроек и вывода `proxy_on`/`proxy_off`. Windows-часть -прежнего дизайна не меняется. - -Сейчас Bash-функции жёстко используют внешний Hiddify на -`hproxy.local:12334`. Принесённый прототип `scripts/setup-xray-proxy.sh` -умеет установить и запустить Xray из готового JSON, но смешивает установку и -запуск, не разбирает ссылки Hiddify и небезопасно ищет чужие процессы через -`pkill`. - -Xray принимает конфигурационные файлы, а не `vless://`-ссылки. При этом его -SOCKS-вход принимает SOCKS и HTTP на одном порту, поэтому существующая схема -proxy-переменных совместима с локальным Xray без второго порта. См. +Xray принимает конфигурационные файлы, а не `vless://`-ссылки. Его SOCKS-вход +принимает SOCKS и HTTP на одном порту, поэтому прежняя схема proxy-переменных +подходит локальному Xray без второго порта. См. [командную строку Xray](https://xtls.github.io/en/document/command.html) и [SOCKS-вход](https://xtls.github.io/en/config/inbounds/socks.html). ## Цели -- Дать headless Linux-серверу управляемый пользовательский Xray без systemd и - прав root. -- Сохранить явные `pclaude` и `pcodex`, которые нельзя случайно запустить через - другой прокси или без настроенного прокси. -- Принимать одну точечную VLESS-ссылку Hiddify и преобразовывать её в клиентский - JSON Xray. -- Сохранить совместимость Bash-функций с внешним Hiddify. -- Позволить настраивать машинный `NO_PROXY` без правки файлов репозитория. -- Поддержать самостоятельную установку из отдельно скопированного каталога - `proxy-client/` без зависимости от общего установщика dotfiles и без - обязательного stow. +- Управляемый пользовательский Xray на Linux-сервере без systemd и root. +- Явные `pclaude` и `pcodex`, которые нельзя случайно запустить через другой + прокси или без прокси. +- Одна VLESS-ссылка Hiddify превращается в клиентский JSON Xray. +- Внешний Hiddify работает, как раньше. +- Машинный `NO_PROXY` настраивается без правки файлов репозитория. +- Каталог `proxy-client/` можно скопировать отдельно и поставить своим + установщиком, без остальных dotfiles и без Stow. ## Не входит в объём - Windows, PowerShell, Windows Codex launcher и VS Code Server. -- systemd, автозапуск после перезагрузки, sudo, firewall, TUN и изменение - системной маршрутизации. -- Автоматический выбор между Xray и Hiddify или переход на запасной прокси. -- Одновременное управление несколькими профилями одного Unix-пользователя. -- Универсальный импорт подписок или полного JSON-экспорта Hiddify. -- Протоколы и сочетания за пределами двух явно поддержанных семейств. -- pip, venv и любые сторонние Python-библиотеки. -- Сетевая изоляция голых `claude` и `codex`: как и раньше, гарантию намерения - дают явные команды с префиксом `p`. +- systemd, автозапуск, sudo, firewall, TUN и системная маршрутизация. +- Автоматический выбор между Xray и Hiddify или запасной прокси. +- Несколько одновременных профилей у одного Unix-пользователя. +- Импорт подписок или полного JSON-экспорта Hiddify. +- Протоколы за пределами двух поддержанных семейств VLESS. +- pip, venv и сторонние Python-библиотеки. +- Сетевая изоляция голых `claude` и `codex`: намерение выражают команды с + префиксом `p`. ## Рассмотренные варианты -### Только Bash +- **Только Bash.** URL-кодирование, JSON, контрольные суммы и управление + процессом делают Bash хрупким и плохо тестируемым. +- **Bash-контроллер и Python-преобразователь.** Граница между двумя языками + усложняет ошибки, тесты и единый источник настроек. +- **Один Python CLI и тонкий Bash-модуль.** Выбран. Python отвечает за данные, + установку Xray, процессы и запуск дочерних команд. Bash нужен только там, где + меняется окружение текущей shell-сессии. +- **Без менеджера**: готовые JSON и Xray вручную в tmux. Почти нет кода, но не + решает главного — безопасного `pclaude`/`pcodex` из ссылки Hiddify. -Ближе всего к принесённому прототипу, но URL-кодирование, JSON, проверка схемы, -контрольные суммы и безопасное управление процессом делают Bash-реализацию -хрупкой и плохо тестируемой. +Для отдельно скопированного пакета рассматривались: только инструкция для +ручных ссылок (легко забыть шаги), копирование файлов в `$HOME` (сложно +обновлять), обязательный Stow (лишняя зависимость на серверах) и свой +установщик. Выбран свой установщик, который ставит прямые ссылки: один способ, +нет второй копии файлов. -### Bash-контроллер и отдельный Python-преобразователь +## Решение -Оставляет жизненный цикл в Bash, а разбор VLESS переносит в Python. Граница -между двумя реализациями усложняет ошибки, тесты и единый источник настроек. +### Компоненты -### Один Python CLI и тонкий Bash-модуль +`proxy-client` — один исполняемый файл на Python 3.8+ без внешних библиотек: +установка Xray, запуск и остановка, проверка доступности, разбор VLESS, запуск +дочерней команды через прокси. Команды: `install`, `start`, `stop`, `status`, +`env`, `run`. Без подкоманды ничего не делается. -Python отвечает за данные, установку Xray, процессы и запуск дочерних команд. -Bash нужен только там, где необходимо изменить окружение текущей shell-сессии. -Это выбранный вариант. +`shell.bash` — единственный владелец `proxy_on`, `proxy_off`, `pclaude`, +`pcodex` и `myip`; в `bash/.bash_functions` их нет, чтобы не зависеть от +порядка `source`. Модуль сам конфиг не читает: -### Не создавать менеджер +- `pclaude` и `pcodex` вызывают `proxy-client run -- COMMAND ARG...`; +- `proxy_on` берёт готовые переменные из `proxy-client env` и применяет их, + только если команда завершилась успешно; `eval` не используется; +- `proxy_off` очищает переменные в обоих регистрах. -Можно хранить готовые JSON и вручную запускать Xray в tmux. Вариант почти не -требует кода, но не решает основную боль: безопасный запуск `pclaude` и -`pcodex` из точечной ссылки Hiddify. +`proxy_on`/`proxy_off` больше не запрашивают внешний IP: это замедляло +переключение. Для проверки есть `myip` и `proxy-client status`. -### Доставка отдельного пакета +### Настройки -Рассмотрены четыре способа подключить отдельно скопированный каталог: +Два файла в `~/.config/proxy-client/`: -- оставить только инструкции для stow и ручных симлинков — просто, но сохраняет - легко забываемые шаги; -- копировать файлы в домашний каталог — не требует сохранять исходный каталог, - но усложняет обновления и расходится с моделью dotfiles; -- требовать stow — даёт привычное управление ссылками, но добавляет - необязательную зависимость на удалённых серверах; -- дать пакету собственный установщик, который использует stow при наличии и - создаёт те же симлинки напрямую без него. +- `config` — несекретный машинный INI: тип `xray` или `external`, имя, адрес, + порт, добавка к `NO_PROXY`. Если файла нет, используется прежний Hiddify на + `hproxy.local:12334`. +- `default.url` — секретная VLESS-ссылка с правами 600; `start -f` берёт другой + файл. -Выбран последний вариант. Он сохраняет один результат установки при разном -составе машины и не требует второй копии обновляемых файлов. +Они разделены, потому что первый описывает политику машины, а второй — секрет, +который меняется независимо. Выбран INI, а не TOML: `tomllib` появился только +в Python 3.11. -## Рекомендуемое решение +### Явный тип прокси -### Границы компонентов +`xray` значит: нужен Xray, запущенный через `proxy-client start`. Если его нет, +агент не запускается. `external` значит: проверяется только доступность адреса +и порта. Ни один тип не переходит на другой сам — предсказуемость канала важнее +удобства. -`proxy-client` — один исполняемый файл на Python 3.8+ без внешних библиотек. Он -отвечает за установку Xray, запуск и остановку процесса, диагностику, -преобразование VLESS и запуск произвольной дочерней команды через прокси. -Нейтральное публичное имя описывает клиентскую прокси-подсистему целиком: -внешний прокси и управляемый Xray. Название `xray` остаётся у конкретного -движка и значения `type = xray`, где оно действительно важно. +`start` работает только при `type=xray` и loopback-адресе в конфиге. Поэтому +нельзя поднять локальный Xray, оставив `pclaude` направленным во внешний +Hiddify. `stop` от типа не зависит: после переключения на `external` старый +Xray всё равно можно остановить. -Переносимый `shell.bash` — единственный владелец `proxy_on`, `proxy_off`, -`pclaude`, `pcodex`, `myip` и внутренних proxy-помощников. При реализации они -удаляются из `bash/.bash_functions`, чтобы результат не зависел от порядка -`source`. `update_hproxy` остаётся там как отдельная WSL-утилита управления -`/etc/hosts`, а не часть переносимого интерфейса прокси. Модуль не разбирает -машинный конфиг самостоятельно: +Один порт на HTTP и SOCKS, по умолчанию `12334`; порт настраивается, чтобы +разные пользователи одной машины не мешали друг другу. -- `pclaude` и `pcodex` делегируют запуск форме - `proxy-client run -- COMMAND ARG...`, где `--` отделяет параметры клиента; -- `proxy_on` получает от `proxy-client env` проверенный набор proxy-переменных и - применяет его к текущей Bash-сессии; -- `proxy_off` очищает верхний и нижний регистр proxy-переменных. +### VLESS-ссылки -Общий `bash/.bash_functions` и модуль клиента не зависят друг от друга. В составе -dotfiles `.bash_custom` подключает их двумя независимыми строками. Без -остальных dotfiles пользователь добавляет только один `source` модуля клиента в -`.bashrc`. Для неинтерактивных сценариев и других оболочек остаётся прямой -`proxy-client run`. +Поддержаны две семьи: TCP/RAW с REALITY и XHTTP с TLS. Неизвестный или +повторный query-параметр — отказ: он может менять смысл соединения, и его +нужно добавить в разбор явно. Остальное проверяет `xray run -test`. -`proxy-client env` сначала выполняет ту же проверку доступности, что и `run`, а -затем печатает восемь строк `KEY=value`: HTTP, HTTPS, ALL и NO_PROXY в верхнем -и нижнем регистре. `shell.bash` сначала целиком получает успешный ответ, затем -разбирает строки по первому `=`, принимает только известные ключи и лишь после -полной проверки экспортирует полученные значения через `printf -v`. `eval` не -используется, частичного изменения окружения при ошибке нет. +Hiddify Manager добавляет `allowInsecure`/`insecure` даже к профилям с +нормальным сертификатом +([Hiddify-Manager#5438](https://github.com/hiddify/Hiddify-Manager/issues/5438)), +а Xray [26.2.6](https://github.com/XTLS/Xray-core/releases/tag/v26.2.6) эту +опцию удалил. Поэтому поля принимаются и игнорируются с предупреждением; +TLS-проверку задают `pcs` и `vcn`. -У `env` stdout зарезервирован только для восьми машинно-читаемых строк. -Предупреждения и ошибки идут в stderr; обычная безопасная сводка этой командой -не печатается. - -Совместимость существующих Bash-команд означает сохранение имён функций, -набора переменных, fail-fast перед `pclaude`/`pcodex` и изоляции окружения -дочернего процесса. Автоматический запрос внешнего IP из `proxy_on` и -`proxy_off` удаляется: он замедляет переключение и смешивает изменение -окружения с диагностикой. Для проверки остаются `myip` и `proxy-client status`. - -### Два независимых файла настройки - -Машинный файл `~/.config/proxy-client/config` хранит несекретное описание -активного прокси: явный тип `xray` или `external`, отображаемое имя, адрес, порт -и добавку к `NO_PROXY`. Формат — INI из стандартной библиотеки Python. Если -файла нет, прокси-подсистема сохраняет прежний внешний адрес -`hproxy.local:12334` ради обратной совместимости. - -Секретный `~/.config/proxy-client/default.url` содержит одну VLESS-ссылку. -Команда запуска принимает другой файл через `-f`. Профили можно хранить в -нескольких файлах, но активным у пользователя бывает только один. -CLI следует за симлинком, но проверяет права конечного файла: доступ группы или -остальных пользователей считается ошибкой с подсказкой выполнить `chmod 600`. -Файл должен содержать ровно одну непустую строку без комментариев и служебного -текста. - -Базовый `NO_PROXY` всегда включает loopback-адреса и узел самого прокси. -Машинная добавка расширяет его локальными IP и доменами. Документация должна -предупреждать, что поддержка CIDR в `NO_PROXY` различается между программами. - -### Явная семантика прокси - -Тип `xray` означает, что `pclaude` и `pcodex` требуют запущенный процесс, -которым управляет `proxy-client`. Если его нет, агент не запускается, а сообщение -предлагает выполнить `proxy-client start`. - -Тип `external` означает внешний процесс, например Hiddify. Проверяется только -доступность явно заданного адреса и порта. Ни один тип не переходит на другой -автоматически. - -`start` разрешён только при `type=xray`; при `type=external` он завершается с -подсказкой изменить машинный конфиг. Для Xray адрес машинного конфига обязан -быть loopback-адресом, а порт обязан совпадать с создаваемым входом. Так нельзя -случайно поднять локальный Xray, оставив `pclaude` направленным во внешний -Hiddify. - -`status` описывает именно активный тип из машинного конфига: состояние процесса -и порта для Xray либо доступность адреса для external. `stop` от машинного типа -не зависит и завершает записанный управляемый Xray, если он существует. Поэтому -после явного переключения на external старый локальный процесс можно штатно -остановить. - -HTTP, HTTPS и SOCKS используют один локальный порт, по умолчанию `12334`. -Порт остаётся настраиваемым, чтобы разные Unix-пользователи одной машины могли -запускать свои экземпляры без конфликта. - -Локальный вход Xray слушает только loopback: `127.0.0.1` и, если поддержано -выбранной конфигурацией, `::1`. Он никогда не привязывается к `0.0.0.0`, адресу -сетевого интерфейса или имени узла. Это закрывает вход от удалённой сети, но не -изолирует его от других пользователей того же Linux-узла. Первая версия не -добавляет локальную аутентификацию и предполагает доверенных пользователей -машины. - -### Поддерживаемые VLESS-ссылки - -Первая версия поддерживает две семьи: - -- VLESS с TCP/RAW и REALITY; -- VLESS с XHTTP и TLS, включая HTTP/1.1, h2 и h3 через ALPN. - -Преобразователь сохраняет значимые параметры ссылки, проверяет обязательные -поля и отклоняет неизвестное сочетание целиком. Он не должен молча пропускать -неизвестные или несовместимые параметры. Поддержка REALITY опирается на -[официальную схему REALITY](https://xtls.github.io/en/config/transports/reality.html), -а XHTTP с TLS — на -[схему TLS](https://xtls.github.io/en/config/transports/tls.html). - -Для REALITY преобразователь явно сопоставляет UUID с VLESS `id`, узел и порт с -адресом назначения, `type=tcp` с RAW, `flow` с режимом VLESS Vision, `sni` с -`serverName`, `fp` с `fingerprint`, `pbk` с `password`, а `sid` с `shortId`. -`headerType=none` принимается как нейтральное значение, другое значение -отклоняется. `hiddify`, `host` и `alpn` для TCP/REALITY считаются метаданными и -не меняют Xray JSON; безопасная сводка явно помечает `host` и `alpn` как -неиспользованные. Это не скрытая потеря параметра: в целевой -[схеме REALITY Xray 26.3.27](https://github.com/XTLS/Xray-core/blob/v26.3.27/infra/conf/transport_internet.go#L3591-L3636) -нет полей Host или ALPN. Повтор одного query-параметра считается ошибкой. -Остальные правила соответствуют -[стандарту VLESS-ссылок Xray](https://github.com/XTLS/Xray-core/discussions/716). - -Основной сценарий XHTTP/TLS — одиночная ссылка Hiddify с `type=xhttp`, -`security=tls`, `sni`, `alpn`, `path`, `host`, `encryption`, `fp` и, возможно, -`extra`. UUID, узел и порт преобразуются так же, как для REALITY. Остальные -поля сопоставляются явно: - -- `type=xhttp` задаёт транспорт XHTTP; -- `security=tls` включает TLS; -- `sni`, `alpn` и `fp` задают `serverName`, список ALPN и fingerprint; -- `path`, `host` и необязательный `mode` переходят в настройки XHTTP; -- `encryption` задаёт шифрование VLESS и в первой версии должно быть `none`; -- `extra` URL-декодируется, разбирается как JSON-объект без повторяющихся - ключей и целиком передаётся в одноимённую секцию XHTTP; -- `pcs` и `vcn` для Xray 26.3.27 переходят в `pinnedPeerCertSha256` и - `verifyPeerCertByName`; -- `hiddify=1` и `core=xray` принимаются как метаданные источника и не меняют - JSON; -- `headerType=none` принимается как устаревшее нейтральное поле Hiddify, другое - значение отклоняется; -- fragment после `#` используется только как отображаемое имя профиля. - -Если `mode` отсутствует, преобразователь не придумывает его и оставляет выбор -Xray. Параметры query не зависят от порядка, но не могут повторяться. После -перечисленных полей неизвестный параметр считается ошибкой: он может менять -смысл соединения и требует явного добавления в преобразователь. `path`, `host`, -`mode` и `extra` входят в официальный стандарт ссылок Xray; параметры TLS -`pcs`/`vcn` заменяют удалённый `allowInsecure`: -[стандарт VLESS-ссылок](https://github.com/XTLS/Xray-core/discussions/716). - -Перед запуском CLI печатает компактную безопасную сводку: сервер, порт, -транспорт, защиту, SNI, ALPN, Host, наличие и длину пути, fingerprint и локальный -порт. XHTTP `path` может содержать tenant ID или bearer-подобный токен, поэтому -его содержимое никогда не печатается. Обычный режим не выводит подробный журнал -Xray. UUID, REALITY password/public key, short ID и другие чувствительные -значения обозначаются без фрагментов исходного значения. -В создаваемом JSON журнал доступа отключён, а уровень ошибок по умолчанию — -`warning`. - -Hiddify Manager может добавлять `allowInsecure=true` и `insecure=true` даже к -профилю с действительным публичным сертификатом — этот дефект описан в -[hiddify/Hiddify-Manager#5438](https://github.com/hiddify/Hiddify-Manager/issues/5438). -Современный Xray больше не поддерживает `allowInsecure`; удаление зафиксировано -в [релизе 26.2.6](https://github.com/XTLS/Xray-core/releases/tag/v26.2.6). -Преобразователь принимает для обоих полей только точные строчные значения -`true` и `false`. Если присутствуют оба поля, их значения должны совпадать; -конфликт и любое другое написание отклоняются до создания JSON. Для Xray -26.3.27 поля не переносятся в JSON; истинное значение даёт предупреждение, что -сертификат будет проверен обычным способом, а ложное не требует предупреждения. -Соединение после этого может успешно заработать или завершиться ошибкой -сертификата; отдельного раннего отказа нет. +Сводка перед запуском не показывает UUID, ключи REALITY и содержимое XHTTP +`path` (там бывает токен). Ссылка не передаётся аргументом командной строки. ### Установка Xray -`proxy-client install` — отдельная явная операция. Она устанавливает Xray в -`~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем -проверяет SHA-256. Поддерживается только зафиксированный релиз 26.3.27; -параметра версии нет. Официальные релизы публикуют необходимые файлы `.dgst`: -[релиз 26.3.27](https://github.com/XTLS/Xray-core/releases/tag/v26.3.27). - -Для Linux `x86_64`/`amd64` используется `Xray-linux-64.zip`, для -`aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`; остальные архитектуры -отклоняются. Официальный `.dgst` содержит записи `MD5`, `SHA1`, `SHA2-256` и -`SHA2-512`; принимается ровно один полный набор со строчными hex-значениями, -для проверки архива используется `SHA2-256`. Дубликаты, uppercase, отсутствие -записи и мусор отклоняются. Архив и извлечённый бинарник ограничены 64 MiB, -`.dgst` — 4 KiB; фактический размер считается с limit+1 и должен совпадать с -объявленным `Content-Length`. Архив распаковывается в закрытый staging-каталог -внутри `~/.local/bin`, проверенный бинарник запускается с `version`, после чего -одним `os.replace` атомарно переносится в `~/.local/bin/xray`. - -Корректная существующая версия 26.3.27 даёт no-op без сети. Другой файл или -симлинк не заменяется без `--replace`, а каталог не заменяется никогда. -Start-lock берётся до проверки назначения и удерживается до завершения -`os.replace`; живой, сиротский или неподтверждённый managed-state запрещает -установку с подсказкой сначала выполнить `stop`. -После установки CLI проверяет, что первый `xray` из `PATH` разрешается в -установленный файл; иначе завершается с подсказкой поставить `~/.local/bin` в -начало `PATH`, не откатывая готовую установку. `start` всегда показывает -фактический путь и версию выбранного бинарника. - -Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и -запускает этим файлом `xray version`. Первая строка должна начинаться с точного -токена `Xray VERSION`, после полного токена версии допускается только конец -строки либо ASCII-пробел. Принимается только версия 26.3.27; ведущий -мусор, похожий более длинный токен, пустой, неверный, завершившийся ошибкой или -содержащий другую версию вывод запрещает запуск. Тот же разрешённый файл -используется для `run -test` и основного процесса. Версия никогда не выводится -из содержимого VLESS-ссылки. - -`start` никогда не скачивает бинарник. Если `xray` отсутствует в `PATH`, команда -завершается с подсказкой выполнить `proxy-client install`. +`proxy-client install` ставит в `~/.local/bin/xray` официальный архив одного +зафиксированного релиза (сейчас 26.3.27), проверяет SHA-256 по `.dgst` и +заменяет файл атомарно. Официальный установщик Xray не подходит: он требует +root и создаёт systemd-службы. `start` принимает только ту же версию и никогда +ничего не скачивает. ### Жизненный цикл -CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `run`. Запуск -без подкоманды не устанавливает и не стартует ничего. +`start` остаётся на переднем плане как небольшой Python-супервизор с одним +дочерним Xray; ожидаемое место — панель tmux. Отдельный журнал и команда `logs` +не нужны: хватает истории tmux. Второй `start` отсекает блокировка `flock`. +`state.json` хранит PID супервизора и Xray, чтобы `stop` работал из любого +терминала и мог добить Xray, если супервизор убит. -`start` остаётся на переднем плане как небольшой Python-супервизор и занимает -текущую консоль. Он запускает один дочерний Xray без демонизации и без новой -сессии процесса, ждёт его завершения и передаёт ему SIGINT, SIGTERM и SIGHUP. -Ожидаемый способ работы — отдельная панель tmux или другого мультиплексора. -Отсоединение от tmux не останавливает процесс, но закрытие панели или сессии -останавливает Xray. Отдельный постоянный журнал и команда `logs` не нужны: -достаточно истории tmux. +Перед запуском обязательны разбор ссылки, `xray run -test` и открытый порт +после старта. Проверка внешнего IP — только диагностика: её сбой даёт +предупреждение. -Супервизор на всё время работы удерживает неблокирующую эксклюзивную блокировку -`fcntl.flock` в служебном каталоге `~/.local/state/proxy-client/`. Второй `start` -отказывает до запуска процессов и изменения state или сгенерированного -конфига. `state.json` нужен для восстановления после сбоя, но не служит -блокировкой. - -Файл состояния содержит ровно `supervisor_pid`, `xray_pid`, `xray_path`, -`config_path`, `address`, `port`, `supervisor_path`, `supervisor_starttime`, -`xray_starttime` и `boot_id`. Время старта — поле 22 `/proc/PID/stat`; поле -`comm` сначала отделяется по последней `)`. Для обоих процессов проверяются PID, -точное время старта и `boot_id`. У ребёнка дополнительно сверяется канонический -`/proc/PID/exe` с `xray_path`. У Python-супервизора операнд скрипта из -`/proc/PID/cmdline` разрешается относительно `/proc/PID/cwd` и сравнивается с -каноническим `supervisor_path`. Ошибка чтения или разбора означает несовпадение. -Непосредственно перед каждым сигналом идентичность читается повторно. - -Проверка состояния различает три исхода. Для здоровой пары `stop` посылает -SIGTERM супервизору. Если супервизор исчез, но ребёнок подтверждён, `status` -сообщает о сироте с ненулевым кодом, `env` и `run` отказывают, а `stop` посылает -сигнал ребёнку напрямую без требования доступности порта и удаляет state и -конфиг. При живом, но неподтверждённом PID сигналы не посылаются, файлы -сохраняются, а диагностика требует ручной проверки и удаления. Если оба PID -мертвы или `boot_id` не совпадает, устаревшие state и конфиг можно безопасно -удалить без сигналов. - -У пользователя может быть только один управляемый экземпляр. Повторный `start` -не заменяет его другим профилем. Для смены профиля нужны явные `stop` и `start`. - -Перед успешным запуском должны пройти разбор ссылки, проверка поддерживаемого -сочетания и `xray run -test`. После запуска процесс должен оставаться живым, а -локальный порт — принимать соединения. Проверка внешнего IP через прокси носит -диагностический характер: её сбой даёт предупреждение, но не останавливает уже -прошедший обязательные проверки Xray. - -Сгенерированный JSON и временные файлы создаются внутри служебного каталога и -удаляются супервизором после завершения Xray. Каталоги создаются с правами -`0700`, файлы с конфигурацией и состоянием — `0600`. Исходная VLESS-ссылка не -попадает в stdout или stderr при успехе, ошибках разбора и сбое `run -test` и -никогда не передаётся аргументом командной строки. Те же поверхности не содержат -UUID, ключи, short ID и другие уникальные секретные маркеры профиля. - -`run` никогда не запускает Xray. Для типа `xray` он проверяет PID управляемого -процесса и локальный порт; для типа `external` — только явно настроенный адрес и -порт. Лишь после успешной проверки он выставляет proxy-переменные для дочерней -команды. Поэтому `pclaude` и `pcodex` не имеют скрытого автозапуска. +`run` никогда не запускает Xray, поэтому у `pclaude` и `pcodex` нет скрытого +автозапуска. ### Доставка -Компоненты образуют отдельный stow-совместимый каталог `proxy-client/`. Он -доставляет CLI, Bash-модуль и три шаблона: машинного INI для внешнего прокси, -машинного INI для Xray и файла VLESS-ссылки. -Реальные пользовательские файлы не управляются stow и не входят в Git. -CLI раскладывается как `~/.local/bin/proxy-client`. Машинный конфиг, профиль, -модуль `shell.bash` и каталог `examples/` находятся вместе в -`~/.config/proxy-client/`. Шаблоны не подменяют настоящие пользовательские -файлы. +Каталог `proxy-client/` раскладывается в `$HOME` ссылками: CLI в +`~/.local/bin/`, `shell.bash` и `examples/` в `~/.config/proxy-client/`. +Машинный `config` и `default.url` пользователь создаёт сам; в Git они не +входят. -В корне этого каталога находятся самостоятельный `README.md` и пакетный -`install.sh`. Оба файла относятся к исходному каталогу и исключаются из -раскладки stow. README остаётся рядом с кодом при выборочном клонировании или -копировании каталога. Корневой README репозитория только кратко описывает -подсистему и ссылается на него. +- Отдельно: `proxy-client/install.sh` ставит эти три ссылки и по умолчанию + дописывает блок в `~/.bashrc`. Чужие файлы не трогает. Режим прокси, Xray и + сеть не настраивает — это делают отдельные команды по README. +- В составе dotfiles: корневой `install.sh` подключает тот же пакет через Stow, + а `.bash_custom` загружает `shell.bash`. -Пакетный установщик связывает отдельно скопированный каталог с домашним -каталогом и не зависит от положения или наличия остальных dotfiles. Если stow -доступен, установщик использует его; если нет — создаёт тот же набор симлинков -напрямую. Выбранный механизм явно показывается пользователю, но не меняет -результат. Исходный каталог должен оставаться на прежнем пути всё время -использования ссылок. +## Риски -Установщик управляет только тремя публичными артефактами: CLI, `shell.bash` и -каталогом шаблонов. Машинный `config` и закрытый `default.url` не входят в -раскладку stow, никогда не создаются, не заменяются и не связываются -установщиком. Их наличие в исходном каталоге также не должно расширять набор -управляемых путей. - -Домашние `~/.local/bin` и `~/.config/proxy-client` остаются настоящими -каталогами. Stow не должен сворачивать их или их родителей в одну ссылку на -исходное дерево: ссылками становятся только три перечисленных артефакта. Это -одновременно сохраняет место для машинных файлов и делает результат stow -совпадающим с прямой раскладкой через `ln -s`. - -Перед записью установщик проверяет весь набор назначений. Правильная ссылка на -текущий исходный каталог считается уже установленной; любой другой файл, -каталог или симлинк считается конфликтом. При конфликте установщик ничего не -меняет и сообщает все пути, которые пользователь должен разобрать вручную. -Принудительной замены и автоматических резервных копий нет. - -По умолчанию установщик идемпотентно подключает Bash-модуль в `~/.bashrc` и при -необходимости добавляет `~/.local/bin` в `PATH`. Уже существующее подключение -через dotfiles не дублируется. Управляемый блок помечается, повторно не -добавляется и может быть полностью отключён параметром `--no-shell-setup`. - -Установщик не выбирает режим прокси, не создаёт конфигурацию, не читает VLESS, -не устанавливает Xray и не проверяет сеть. После раскладки он проверяет только -локальные требования и исправность CLI и Bash-модуля, затем печатает краткую -подсказку для текущей shell-сессии и направляет пользователя в README за -настройкой режима. Недоступный прокси не делает установку неуспешной. - -Полный корневой `install.sh` продолжает подключать тот же stow-пакет вместе с -остальными dotfiles и не получает отдельной реализации этой логики. - -## Решения и отклонённые альтернативы - -- Выбран Python 3.8+, потому что стандартной библиотеки достаточно для URL, - JSON, INI, ZIP, SHA-256, HTTP, процессов и тестов. Более новый синтаксис и - сторонние пакеты запрещены ради старых и непредсказуемых серверных систем. -- Выбран INI, а не TOML: модуль - [`tomllib`](https://docs.python.org/3/library/tomllib.html) добавлен только в - Python 3.11, что исключило бы штатный Python 3.10 в Ubuntu 22.04. -- Bash оставлен отдельным тонким модулем, потому что дочерний Python-процесс не - может изменить окружение родительской shell-сессии. -- Выбрано публичное имя `proxy-client`, потому что подсистема обслуживает и - внешний прокси, и управляемый Xray. `xray-proxy` отклонено как имя, - выпячивающее одну реализацию, а `proxyctl` — как менее понятное пользователю. -- Все пользовательские файлы собраны в `~/.config/proxy-client/`. Разделение - между общим машинным конфигом и Xray-профилем остаётся логическим, но не - требует помнить два каталога. -- Машинный прокси и VLESS-ссылка разделены: первый файл описывает локальную - политику shell, второй содержит секрет и меняется независимо. -- Выбран один порт, потому что SOCKS-вход Xray совместим с HTTP и не требует - второго локального слушателя. -- Выбран явный тип прокси вместо автообнаружения. Предсказуемость канала важнее - удобства автоматического перехода. -- Выбрана одна активная конфигурация на пользователя. Именованные процессы и - автоматическое распределение портов отложены до появления реальной нужды. -- Официальный системный установщик отклонён: он требует root и создаёт - systemd-службы. Пользовательская установка соответствует выбранной модели. -- Копирование файлов пакета отклонено: оно создаёт отдельные экземпляры, - усложняет обновление и нарушает общую с dotfiles модель симлинков. -- Обязательный stow отклонён как лишняя зависимость. Прямые симлинки дают тот же - результат на машине без stow, а автоматический выбор механизма не меняет - публичное поведение пакета. -- Автоматическая настройка режима, установка Xray и проверка сети отклонены для - установщика пакета. Эти операции зависят от машины, могут требовать секретов - или сети и уже имеют отдельные явные команды и инструкции. -- Готовые JSON и полный импорт Hiddify отклонены как основной интерфейс: точечная - VLESS-ссылка проще для пользователя и ограничивает область преобразования. - -## Риски и меры - -- Формат ссылок Hiddify может расширяться. Строгая проверка и явный отказ - безопаснее частично неверного JSON. -- Проверка открытого порта не доказывает работоспособность маршрута. CLI - разделяет обязательную проверку процесса и необязательную внешнюю диагностику. -- VLESS-ссылка содержит чувствительные данные. CLI сокращает секреты в выводе, - не журналирует их и использует закрытые пользовательские файлы. -- Устаревшие Hiddify-поля `allowInsecure` и `insecure` неоднозначны. Они не - переносятся в Xray 26.3.27, сертификат проверяется обычным способом, а - истинное значение сопровождается предупреждением. -- Старые дистрибутивы могут иметь Python ниже 3.8. CLI проверяет версию до - основной работы и сообщает обнаруженную и требуемую версии. -- Симлинки зависят от постоянного пути исходного каталога. Установщик явно - сообщает эту зависимость и при повторном запуске принимает только ссылки на - текущий источник. -- Изменение `~/.bashrc` может конфликтовать с пользовательским способом загрузки - оболочки. Установщик распознаёт собственный блок и известное подключение из - dotfiles, не дублирует их и предоставляет `--no-shell-setup`. -- Частичная установка может оставить неоднозначное состояние. Все конфликты - назначений проверяются до изменений; проверки охватывают отсутствие новых - ссылок и правок shell при отказе. -- `NO_PROXY` реализуется программами неодинаково. README рекомендует точные IP - и домены, а CIDR описывает как совместимость best-effort. -- Разные пользователи делят сетевое пространство имён. Настраиваемый порт и - явная ошибка конфликта предотвращают случайный запуск на занятом порту, но - loopback без аутентификации не запрещает намеренное подключение другого - локального пользователя. Это принятое ограничение первой версии. - -## Проверка реализации - -- Модульные тесты разбирают обезличенные ссылки обеих поддерживаемых семей и - все показанные варианты ALPN. Отдельный регрессионный пример повторяет форму - типичной ссылки Hiddify XHTTP/TLS с `hiddify`, `core`, `extra`, - `headerType=none`, `allowInsecure` и `insecure`. -- Неподдерживаемые транспорт, защита, отсутствующие обязательные поля и любой - неизвестный query-параметр дают понятный отказ до запуска Xray. -- Сгенерированный JSON проходит `xray run -test` на Xray 26.3.27 для заявленных - сочетаний. Интеграционная проверка подтверждает, что один локальный вход - принимает HTTP и SOCKS. -- Тесты отдельно проверяют удаление устаревших insecure-полей. -- Тесты подтверждают сокращение секретов и отсутствие полной VLESS-ссылки в - stdout, stderr, служебных файлах и аргументах дочернего процесса. -- Установка проверяется на корректной и повреждённой SHA-256 без записи вне - пользовательского каталога. -- Управление процессом покрывает повторный запуск, устаревший PID, чужой PID, - повторно использованный PID, передачу сигналов, аварийно исчезнувший - супервизор, очистку файлов, смену профиля, остановку и конфликт порта. -- Сбой внешней проверки IP не отменяет успешный запуск, но отображается отдельно - от состояния процесса и порта. -- `run` сохраняет аргументы и код возврата, не оставляет proxy-переменные в - родительской Bash-сессии и не запускает команду при недоступном прокси. -- `env` не печатает частичный результат при ошибке, а `shell.bash` отклоняет - неизвестные строки без `eval` и без частичного экспорта. -- Регрессионные Bash-тесты подтверждают прежнее поведение Hiddify при отсутствии - машинного конфига, оба регистра proxy-переменных, настраиваемый `NO_PROXY` и - отсутствие автоматического запроса внешнего IP в `proxy_on`/`proxy_off`. -- Проверки самостоятельной установки покрывают путь со stow и без него, - одинаковый набор итоговых ссылок, запуск не из каталога пакета и каталог с - пробелами в пути. -- В обоих путях домашние каталоги остаются обычными каталогами, а пакетные - `README.md` и `install.sh` не появляются в домашнем каталоге через stow. -- Повторный запуск не меняет уже правильную установку. Конфликт любого - назначения обнаруживается до записи; чужие файлы, каталоги и ссылки остаются - неизменными. -- Машинный `config` и закрытый `default.url` не управляются установщиком даже - при их наличии в исходном каталоге или домашнем каталоге. -- Настройка Bash проверяется для нового `~/.bashrc`, уже помеченного блока, - подключения из полного dotfiles и режима `--no-shell-setup`. Добавление - `~/.local/bin` не дублируется. -- Локальная проверка установки не обращается к сети и не требует доступного - прокси. Неисправный или отсутствующий машинный конфиг не отменяет успешную - раскладку пакета. -- Ручная проверка выполняется минимум на Ubuntu с Python 3.10 и на одной целевой - удалённой системе. РЕД ОС, Astra Linux и Alt Linux проверяются по мере - доступности: недоступная среда явно записывается как неподтверждённая, а - поддержка заявляется только после проверки версии Python и полного прогона. - -## Влияние на документацию - -Основная инструкция находится в `proxy-client/README.md` рядом с -реализацией. Она должна описывать: - -- различие внешнего Hiddify и управляемого Xray; -- самостоятельный `install.sh`, его автоматический выбор stow или прямых - симлинков, требование сохранять исходный каталог и режим - `--no-shell-setup`; -- границу между управляемыми файлами пакета и создаваемыми вручную машинным - конфигом и закрытым профилем; -- создание машинного INI из шаблона и отдельного файла VLESS-ссылки; -- команды управления, `proxy_on/off`, `pclaude/pcodex`; -- настройка `NO_PROXY`, ограничения CIDR и диагностика; -- поддерживаемые семейства VLESS и современную проверку сертификата; -- отсутствие systemd, автозапуска и автоматического перехода между прокси. - -Корневой README получает только короткий обзор, пример подключения пакета и -ссылку на полную инструкцию клиента. - -Шаблоны должны содержать только вымышленные значения. Настоящие VLESS-ссылки, -UUID и ключи никогда не входят в репозиторий. +- Формат ссылок Hiddify расширяется. Строгий отказ на неизвестном параметре + лучше частично неверного JSON, но новые ссылки могут потребовать правки + разбора. +- Открытый порт не доказывает, что маршрут работает. Проверка внешнего IP + отделена от обязательных проверок. +- Loopback без аутентификации открыт другим пользователям того же узла. + Пользователи машины считаются доверенными. +- `NO_PROXY` программы понимают по-разному, особенно CIDR. README советует + точные IP и домены. +- Ссылки установки зависят от пути исходного каталога: переносить его нельзя. +- Поддержка РЕД ОС, Astra Linux и Alt Linux заявляется только после проверки + версии Python и прогона `tests/test_proxy_client_full.sh` на них. +- Шаблоны в `examples/` содержат только вымышленные значения; настоящие ссылки, + UUID и ключи в репозиторий не попадают. diff --git a/proxy-client/README.md b/proxy-client/README.md index 844a20d..a47c510 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -76,12 +76,6 @@ source ~/.bash_custom Если `~/.local/bin` отсутствует в `PATH` и в новых сессиях, добавьте ту же строку `export` в свой Bash-конфиг: корневой установщик dotfiles его не изменяет. -Проверить пакет после правок (из корня dotfiles): - -```bash -tests/test_proxy_client_full.sh -``` - ## Как выбирается прокси Режим определяется только машинным файлом @@ -407,5 +401,18 @@ CLI скачивает официальный архив и файл `.dgst`, с Если файл профиля доступен другим пользователям, `start` не запустится и покажет готовую команду `chmod 600` для конечного файла (у симлинка — для файла, на который он указывает). Служебный каталог закрыт для других пользователей -(`700`, файлы в нём — `600`) и очищается после остановки Xray. Сбой определения внешнего IP показывается как предупреждение и -не останавливает уже готовый процесс. +(`700`, файлы в нём — `600`) и очищается после остановки Xray. Сбой +определения внешнего IP показывается как предупреждение и не останавливает уже +готовый процесс. + +## Изменение кода + +Перед правкой proxy-client прочитайте +[спецификацию](../docs/specs/2026-07-15-proxy-client-design.md): там сказано, +от чего клиент защищается, от чего нет и почему, и где живут тесты. + +Все тесты пакета (из корня dotfiles): + +```bash +tests/test_proxy_client_full.sh +``` From 5ccc75419d6ca333896883e04d14eb2d50d6398f Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 10:19:26 -0400 Subject: [PATCH 7/9] =?UTF-8?q?docs(scratch):=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=BF=D0=B5=D1=86=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B8=20README=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=83=D0=BF=D1=80=D0=BE=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .scratch/proxy-client-simplify/spec.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.scratch/proxy-client-simplify/spec.md b/.scratch/proxy-client-simplify/spec.md index cbcff93..fc038ff 100644 --- a/.scratch/proxy-client-simplify/spec.md +++ b/.scratch/proxy-client-simplify/spec.md @@ -86,3 +86,7 @@ zip-бомба в проверенном по SHA-256 официальном а - `issues/01-cli.md` — CLI и Python-тесты. - `issues/02-installer-shell.md` — установщик пакета, shell-тесты, запускалка. +- После 01 и 02 (делает оркестратор): привести разделы + `docs/specs/2026-07-15-proxy-client-design.md` про установку, жизненный + цикл, доставку, риски и проверку в соответствие с кодом; добавить в + `proxy-client/README.md` ссылку на спецификацию для тех, кто меняет код. From 09f57aa1e7c30264b891265958274b60204242e6 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 10:38:35 -0400 Subject: [PATCH 8/9] =?UTF-8?q?fix(proxy-client):=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BB=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state.json снова хранит адрес и порт: при смене конфига у работающего Xray status/env/run отказывают и советуют перезапустить start. - Ошибки разбора ссылки называют файл профиля. - Тесты: отказ start при чужой версии Xray, ключи REALITY не попадают в вывод, Xray узнаётся после замены файла; машинный конфиг проверяется через CLI; тест пути по умолчанию не зависит от доступности Hiddify. Co-Authored-By: Claude Opus 5.5 (1M context) --- proxy-client/.local/bin/proxy-client | 35 ++++++-- tests/test_proxy_client.py | 74 +++++++++-------- tests/test_proxy_client_managed.py | 115 +++++++++++++++++++++++---- 3 files changed, 168 insertions(+), 56 deletions(-) diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index 1ca3079..f96ca43 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -215,7 +215,7 @@ def check_reachable(config): def check_proxy(config): if config.proxy_type == "xray": - require_supervised_xray() + require_supervised_xray(config) check_reachable(config) @@ -262,7 +262,7 @@ def write_private_json(path, value): path.chmod(0o600) -def read_profile(path): +def load_profile(path): # Права проверяем у конечного файла: у симлинка они ничего не значат. resolved = path.expanduser().resolve(strict=True) if resolved.stat().st_mode & 0o077: @@ -277,7 +277,10 @@ def read_profile(path): raise ProxyError( "файл профиля {} должен содержать ровно одну непустую VLESS-ссылку".format(resolved) ) - return lines[0] + try: + return parse_vless(lines[0]) + except ProxyError as error: + raise ProxyError("файл профиля {}: {}".format(resolved, error)) def parse_query(raw_query): @@ -581,7 +584,8 @@ def read_state(): path = state_path() try: state = json.loads(path.read_text(encoding="utf-8")) - return {key: state[key] for key in ("supervisor_pid", "xray_pid", "xray_path")} + keys = ("supervisor_pid", "xray_pid", "xray_path", "address", "port") + return {key: state[key] for key in keys} except FileNotFoundError: raise ProxyError("управляемый Xray не запущен; выполните proxy-client start") except (ValueError, TypeError, KeyError): @@ -634,12 +638,21 @@ def managed_xray_running(): return False -def require_supervised_xray(): - if not supervisor_running(managed_state()): +def require_supervised_xray(config): + state = managed_state() + if not supervisor_running(state): raise ProxyError( "Xray работает без proxy-client start (его процесс завершён); " "выполните proxy-client stop" ) + if (config.address, config.port) != (state["address"], state["port"]): + raise ProxyError( + "Xray запущен на {}:{}, а {} указывает {}:{}; чтобы применить конфиг, " + "выполните proxy-client stop, затем proxy-client start".format( + state["address"], state["port"], config_home() / "config", + config.address, config.port, + ) + ) def wait_until(condition, timeout): @@ -809,7 +822,7 @@ def run_managed_xray(config, profile_file): remove_state_files() check_port_available(ipaddress.ip_address(config.address), config.port) profile_path = Path(profile_file) if profile_file else config_home() / "default.url" - profile = parse_vless(read_profile(profile_path)) + profile = load_profile(profile_path) executable = resolve_xray() check_xray_version(executable) write_private_json( @@ -826,7 +839,13 @@ def run_managed_xray(config, profile_file): child = subprocess.Popen([executable, "run", "-c", str(generated_config_path())]) write_private_json( state_path(), - {"supervisor_pid": os.getpid(), "xray_pid": child.pid, "xray_path": executable}, + { + "supervisor_pid": os.getpid(), + "xray_pid": child.pid, + "xray_path": executable, + "address": config.address, + "port": config.port, + }, ) if not wait_until( lambda: port_open(config.address, config.port), max(1.0, preflight_timeout()) diff --git a/tests/test_proxy_client.py b/tests/test_proxy_client.py index d84d8e9..2b12006 100644 --- a/tests/test_proxy_client.py +++ b/tests/test_proxy_client.py @@ -152,8 +152,12 @@ class ExternalProxyCliTest(unittest.TestCase): self.assertIn("только для type=xray", result.stderr) def test_missing_config_uses_hiddify_default(self): + # Доступен ли Hiddify на этой машине, неважно: и отчёт о доступности, + # и ошибка называют адрес по умолчанию. result = self.run_cli("status", PROXY_PREFLIGHT_TIMEOUT="0.05") - self.assertIn("Hiddify (hproxy.local:12334)", result.stderr) + output = result.stdout + result.stderr + self.assertIn("Hiddify", output) + self.assertIn("hproxy.local:12334", output) def test_broken_config_symlink_is_an_error(self): self.config_dir.mkdir() @@ -178,24 +182,37 @@ class ExternalProxyCliTest(unittest.TestCase): self.assertIn("положительным числом", result.stderr) -class MachineConfigTest(unittest.TestCase): +class MachineConfigCliTest(unittest.TestCase): + """Машинный конфиг проверяем через команду: код выхода и путь в ошибке.""" + VALID = ( "[proxy]\ntype = external\nname = Test\naddress = 127.0.0.1\n" "port = 12334\nno_proxy_extra =\n" ) - def parse(self, text): - return MODULE["parse_config"](text) + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.path = self.root / "proxy-client/config" + self.path.parent.mkdir() - def test_valid_config(self): - config = self.parse(self.VALID.replace("no_proxy_extra =", "no_proxy_extra = a, b")) - self.assertEqual( - ("external", "Test", "127.0.0.1", 12334, ("a", "b")), - (config.proxy_type, config.name, config.address, config.port, config.no_proxy_extra), + def start_with(self, text): + # start с type=external отказывает сразу после чтения конфига: + # сеть и Xray не нужны. + self.path.write_text(text, encoding="utf-8") + return subprocess.run( + [str(CLI), "start"], + env=dict(os.environ, XDG_CONFIG_HOME=str(self.root), PYTHONDONTWRITEBYTECODE="1"), + text=True, + capture_output=True, + timeout=15, + check=False, ) - def test_invalid_structure_and_values(self): + def test_invalid_configs_are_rejected_with_path(self): cases = { + "not ini": "not an ini\n", "unknown section": self.VALID + "[other]\nkey = value\n", "unknown key": self.VALID + "extra = value\n", "duplicate key": self.VALID + "port = 12335\n", @@ -210,33 +227,20 @@ class MachineConfigTest(unittest.TestCase): "no_proxy_extra =", "no_proxy_extra = one.example,,two.example" ), } - for label, text in cases.items(): - with self.subTest(label): - # Ошибки синтаксиса INI приходят от configparser; load_config - # добавляет к ним путь к файлу. - with self.assertRaises((ProxyError, MODULE["configparser"].Error)): - self.parse(text) - - def test_addresses(self): - for address in ("127.0.0.1", "2001:db8::1", "proxy-1.example", "trailing.example."): - with self.subTest(address): - self.assertEqual( - address, self.parse(self.VALID.replace("127.0.0.1", address)).address - ) for address in ("http://proxy", "[::1]", "has space", "proxy/path", "user@proxy", "прокси.example", "fe80::1%eth0"): - with self.subTest(address): - with self.assertRaisesRegex(ProxyError, "address"): - self.parse(self.VALID.replace("127.0.0.1", address)) + cases["address " + address] = self.VALID.replace("127.0.0.1", address) + for label, text in cases.items(): + with self.subTest(label): + result = self.start_with(text) + self.assertEqual(1, result.returncode) + self.assertIn("Ошибка: машинный конфиг {}:".format(self.path), result.stderr) - def test_load_error_names_config_file(self): - with tempfile.TemporaryDirectory() as root: - path = Path(root) / "proxy-client/config" - path.parent.mkdir() - path.write_text("not an ini\n", encoding="utf-8") - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": root}): - with self.assertRaisesRegex(ProxyError, str(path)): - MODULE["load_config"]() + def test_valid_addresses_are_accepted(self): + for address in ("127.0.0.1", "2001:db8::1", "proxy-1.example", "trailing.example."): + with self.subTest(address): + result = self.start_with(self.VALID.replace("127.0.0.1", address)) + self.assertIn("только для type=xray", result.stderr) UUID = "11111111-2222-4333-8444-555555555555" @@ -538,6 +542,8 @@ class XrayInstallTest(unittest.TestCase): "supervisor_pid": os.getpid(), "xray_pid": os.getpid(), "xray_path": os.path.realpath("/proc/self/exe"), + "address": "127.0.0.1", + "port": 1, } ), encoding="utf-8", diff --git a/tests/test_proxy_client_managed.py b/tests/test_proxy_client_managed.py index b8fabe9..287a69d 100644 --- a/tests/test_proxy_client_managed.py +++ b/tests/test_proxy_client_managed.py @@ -2,6 +2,7 @@ """Команда proxy-client с управляемым Xray: argv → код выхода, вывод, файлы, процессы.""" import json import os +import shutil import signal import socket import subprocess @@ -21,6 +22,12 @@ PROFILE = ( "vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none" "&sni=tls.example&fp=firefox&alpn=h2&host=host.example&path={}#Synthetic" ).format(UUID, quote(PATH_SECRET, safe="")) +PBK = "PUBLIC_KEY_SENTINEL_7f8a" +SID = "SHORT_ID_SENTINEL_9b3c" +REALITY_PROFILE = ( + "vless://{}@edge.example:443?type=tcp&security=reality&encryption=none" + "&flow=xtls-rprx-vision&sni=front.example&fp=chrome&pbk={}&sid={}#Synthetic" +).format(UUID, PBK, SID) READY = "остановка: Ctrl+C или proxy-client stop" # Поддельный Xray — симлинк на интерпретатор Python: тогда /proc/PID/exe @@ -73,7 +80,8 @@ class ManagedXrayTest(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) + self.root = Path(os.path.realpath(temporary.name)) + self.addCleanup(self.kill_strays) bin_dir = self.root / "bin" bin_dir.mkdir() self.xray = bin_dir / "xray" @@ -102,8 +110,8 @@ class ManagedXrayTest(unittest.TestCase): encoding="utf-8", ) - def write_profile(self, path, mode=0o600): - path.write_text(PROFILE + "\n", encoding="utf-8") + def write_profile(self, path, mode=0o600, text=PROFILE): + path.write_text(text + "\n", encoding="utf-8") path.chmod(mode) return path @@ -118,10 +126,10 @@ class ManagedXrayTest(unittest.TestCase): check=False, ) - def start(self, **environment): + def start(self, *arguments, **environment): """Запускает start и ждёт строку готовности; возвращает процесс.""" process = subprocess.Popen( - [str(CLI), "start"], + [str(CLI), "start"] + list(arguments), cwd=str(self.root), env=dict(self.environment, **environment), text=True, @@ -141,14 +149,17 @@ class ManagedXrayTest(unittest.TestCase): watchdog.cancel() self.fail("start не дошёл до готовности: " + process.communicate()[1]) + def kill_strays(self): + # Все процессы теста (CLI и поддельный Xray) работают в self.root. + for entry in Path("/proc").iterdir(): + try: + if os.readlink(str(entry / "cwd")) == str(self.root): + os.kill(int(entry.name), signal.SIGKILL) + except (OSError, ValueError): + pass + def kill_leftovers(self, process): - state = self.state_dir / "state.json" - if state.exists(): - pid = json.loads(state.read_text(encoding="utf-8"))["xray_pid"] - if pid_alive(pid): - os.kill(pid, signal.SIGKILL) - if process.poll() is None: - process.kill() + self.kill_strays() process.communicate() def finish(self, process): @@ -165,7 +176,7 @@ class ManagedXrayTest(unittest.TestCase): self.assertFalse((self.state_dir / "client.json").exists()) def assert_no_secrets(self, text): - for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe="")): + for secret in (UUID, PATH_SECRET, quote(PATH_SECRET, safe=""), PBK, SID): self.assertNotIn(secret, text) def test_full_lifecycle(self): @@ -230,7 +241,13 @@ class ManagedXrayTest(unittest.TestCase): self.state_dir.mkdir(parents=True) (self.state_dir / "state.json").write_text( json.dumps( - {"supervisor_pid": 999999998, "xray_pid": 999999999, "xray_path": "/x"} + { + "supervisor_pid": 999999998, + "xray_pid": 999999999, + "xray_path": "/x", + "address": "127.0.0.1", + "port": self.port, + } ), encoding="utf-8", ) @@ -292,6 +309,76 @@ class ManagedXrayTest(unittest.TestCase): self.assertEqual(0, process.returncode) self.assertIn("Предупреждение: внешний IP", stderr) + def test_reality_secrets_stay_out_of_start_output(self): + profile = self.write_profile(self.root / "reality.url", text=REALITY_PROFILE) + process = self.start("-f", str(profile)) + process.send_signal(signal.SIGINT) + stdout, stderr = self.finish(process) + self.assertIn("Секреты REALITY: скрыты", stdout) + self.assert_no_secrets(stdout + stderr) + + def test_wrong_xray_version_is_refused_before_launch(self): + (self.root / "version").write_text("print('Xray 26.3.26 (fake)')\n", encoding="utf-8") + result = self.run_cli("start") + self.assertEqual(1, result.returncode) + self.assertIn("не сообщил версию Xray 26.3.27", result.stderr) + self.assert_no_state_files() + + def test_replaced_xray_file_is_still_recognised_and_stopped(self): + # Нужен настоящий файл, а не симлинк: только тогда замена файла видна + # в /proc/PID/exe как «(deleted)». Копия интерпретатора находит свою + # стандартную библиотеку через ../lib. + interpreter = Path(os.path.realpath(sys.executable)) + self.xray.unlink() + shutil.copy2(str(interpreter), str(self.xray)) + (self.root / "lib").symlink_to(interpreter.parent.parent / "lib") + process = self.start() + xray_pid = self.xray_pid() + + replacement = self.xray.with_name("xray.new") + shutil.copy2(str(self.xray), str(replacement)) + os.replace(str(replacement), str(self.xray)) + self.assertTrue(os.readlink("/proc/{}/exe".format(xray_pid)).endswith(" (deleted)")) + + status = self.run_cli("status") + self.assertEqual(0, status.returncode, status.stderr) + stopped = self.run_cli("stop") + self.assertEqual(0, stopped.returncode, stopped.stderr) + self.finish(process) + self.assertFalse(pid_alive(xray_pid)) + self.assert_no_state_files() + + def test_changed_machine_port_is_refused_while_xray_runs(self): + process = self.start() + managed_port = self.port + with socket.socket() as other: + other.bind(("127.0.0.1", 0)) + other.listen() + self.port = other.getsockname()[1] + self.write_machine_config() + marker = self.root / "child-ran" + for command in (("status",), ("env",), ("run", "--", "touch", str(marker))): + with self.subTest(command=command): + result = self.run_cli(*command) + self.assertEqual(1, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn(str(self.config_dir / "config"), result.stderr) + self.assertIn(":{}".format(managed_port), result.stderr) + self.assertIn("proxy-client stop", result.stderr) + self.assertFalse(marker.exists()) + process.send_signal(signal.SIGINT) + self.finish(process) + + def test_bad_link_error_names_profile_file(self): + target = self.root / "real profile.url" + target.write_text("vless://not-a-link\n", encoding="utf-8") + target.chmod(0o600) + link = self.root / "link.url" + link.symlink_to(target) + result = self.run_cli("start", "-f", str(link)) + self.assertEqual(1, result.returncode) + self.assertIn("Ошибка: файл профиля {}: ".format(target), result.stderr) + if __name__ == "__main__": unittest.main() From 4e7777527fdf74cc9dee103813350f8d0a0a5623 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 28 Sep 2026 10:38:35 -0400 Subject: [PATCH 9/9] =?UTF-8?q?chore:=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=B2=20master?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../proxy-client-simplify/issues/01-cli.md | 64 ------------- .../issues/02-installer-shell.md | 58 ------------ .scratch/proxy-client-simplify/spec.md | 92 ------------------- 3 files changed, 214 deletions(-) delete mode 100644 .scratch/proxy-client-simplify/issues/01-cli.md delete mode 100644 .scratch/proxy-client-simplify/issues/02-installer-shell.md delete mode 100644 .scratch/proxy-client-simplify/spec.md diff --git a/.scratch/proxy-client-simplify/issues/01-cli.md b/.scratch/proxy-client-simplify/issues/01-cli.md deleted file mode 100644 index 6633199..0000000 --- a/.scratch/proxy-client-simplify/issues/01-cli.md +++ /dev/null @@ -1,64 +0,0 @@ -# 01. Упростить CLI proxy-client и его Python-тесты - -Status: ready-for-agent - -Спецификация: `../spec.md` (читать целиком — там список того, что сохраняем). - -## Файлы - -- `proxy-client/.local/bin/proxy-client` -- `tests/test_proxy_client.py`, `tests/test_proxy_client_managed.py` - (можно слить, переименовать или разнести по-новому — как станет проще) -- `proxy-client/README.md` — все разделы, **кроме** «Установка» и «Ручное - подключение без установщика» (их правит задача 02; при конфликте правки - перечитай файл и повтори). -- `proxy-client/.config/proxy-client/examples/` — если нужно. - -Не трогать: `proxy-client/install.sh`, `shell.bash`, shell-тесты. - -## Что сделать - -Пункты 1–4 и 6 спецификации для CLI. Затем привести тесты к пяти местам -проверок из спецификации: - -- Все проверки разбора ссылки (ALPN, legacy insecure, некорректные ссылки, - соответствие полей REALITY/XHTTP, pcs/vcn, extra, шифрование) — табличные - тесты `parse_vless`/`build_xray_config`, без запуска процессов. -- CLI-тесты управляемого Xray: один полный жизненный цикл (start → status/env/ - run → stop → файлы удалены, права 600/700, секретов нет в выводе), Ctrl+C, - SIGKILL супервизора → `stop` добивает Xray, устаревшие файлы после смерти - процессов, занятый порт, не-loopback адрес, права профиля (с новой - подсказкой), сбой `xray run -test` не оставляет файлов, сбой определения - внешнего IP — лишь предупреждение, второй `start` при работающем — отказ. - Каждый сценарий — один запуск. Готовность ждать по строке вывода, а не - опросом `status` в цикле, если это упрощает. -- Удалить тесты-пустышки: проверку тестовой подделки сокета, проверку, что - поддельный xray отвергает не-JSON, тесты порядка внутренних вызовов, всё, - что подменяет функции модуля через `__globals__`. Подделку сокета - (`checking_socket_setup`/`run_cli_with_socket_setup` через runpy) заменить - настоящим слушающим сокетом на 127.0.0.1 (и закрытым портом для «недоступен»). -- Тесты удалённых возможностей (ограничения размера, строгий `.dgst`, - запрет заглавных букв в хеше, «каталог установки внутри HOME», boot_id, - повторный PID, подмена xray_path, способы вызова супервизора и т. п.) уходят - вместе с возможностями. -- Новая подсказка о правах — тест первым (красный → зелёный). - -Упрощай и сам код: если функция после правок становится проходной — встрой. -Сообщения об ошибках — на русском, коротко, с путём к файлу там, где речь о -файле. Сохраняй совместимость с Python 3.8 (без `match`, `str.removeprefix`, -`|` в типах и т. п.). - -## Готово, когда - -- `python3 -m unittest discover -s tests` зелёный; прогон Python-тестов - proxy-client заметно быстрее нынешних ~75 с. -- Если есть `uv`: `uv run --python 3.8 python -m unittest discover -s tests` - тоже зелёный. -- Реальная проверка: сгенерированный конфиг для синтетических ссылок REALITY - и XHTTP с шифрованием проходит `xray run -test` (xray 26.3.27 есть в - `~/.local/bin/xray`). -- README описывает текущее поведение, без упоминаний удалённого. -- Не коммитить: коммиты делает оркестратор. - -Отчёт: что удалено из кода и тестов (строки до/после, время прогона до/после), -какие решения принял сам, что осталось спорным. diff --git a/.scratch/proxy-client-simplify/issues/02-installer-shell.md b/.scratch/proxy-client-simplify/issues/02-installer-shell.md deleted file mode 100644 index b567e14..0000000 --- a/.scratch/proxy-client-simplify/issues/02-installer-shell.md +++ /dev/null @@ -1,58 +0,0 @@ -# 02. Упростить установщик пакета, shell-тесты и запускалку - -Status: ready-for-agent - -Спецификация: `../spec.md` (читать целиком). - -## Файлы - -- `proxy-client/install.sh` -- `proxy-client/.config/proxy-client/shell.bash` — только если упрощение - очевидно; поведение `proxy_on`/`proxy_off`/`pclaude`/`pcodex` не меняется. -- `tests/test_proxy_client_install.sh`, `tests/test_proxy_launchers.sh`, - `tests/test_proxy_client_full.sh` -- `proxy-client/README.md` — только разделы «Установка» и «Ручное подключение - без установщика» (остальное правит задача 01; при конфликте правки - перечитай файл и повтори). - -Не трогать: `proxy-client/.local/bin/proxy-client`, Python-тесты, корневой -`install.sh` и `tests/test_install.sh` (кроме случая, если они ломаются от -твоих правок — тогда минимально поправь и сообщи). - -## Что сделать - -1. Пункт 5 спецификации: `install.sh` ставит три симлинка напрямую (`ln -s`), - без Stow и `STOW_IGNORE_PATTERNS`. Повторный запуск — ничего не меняет. - Чужие файлы не перезаписывает и перечисляет все конфликты сразу (битая - ссылка — тоже конфликт). `~/.local` и `~/.config`, которые сами ссылки, - допустимы. Блок в `~/.bashrc` — дописать, если нет маркера; - `--no-shell-setup` сохраняется. Машинные `config` и `default.url` никогда - не ссылаются из пакета. -2. Тесты установщика: свежая установка (три ссылки, права, `bash -ic` после - установки находит `proxy-client` и `proxy_on`), повторный запуск ничего не - меняет, конфликты (включая битую ссылку) без частичной установки, машинные - файлы сохраняются, `--no-shell-setup`, родительские каталоги-ссылки - работают. Тесты удалённого поведения — удалить. -3. Тесты функций Bash: вместо переопределения внутренней `_proxy_cli` — - поддельный исполняемый файл через `PROXY_CLIENT_BIN` (посмотри, как это - сделано в `tests/test_chatgpt_proxy_launcher.sh`). Удалить тест голого - `declare -F`, тест «владение перенесено» (охрана давнего рефакторинга) и - ловушки `curl`. Сохранить: `proxy_on` применяет переменные, неуспешный - `env` не меняет окружение, `proxy_off` чистит восемь переменных, - `pclaude`/`pcodex` передают аргументы и код выхода, `.bash_custom` - подключает модули. -4. `tests/test_proxy_client_full.sh` запускает только тесты proxy-client - (Python-тесты proxy-client, env_sync, shell-тесты proxy-client). Прогон - под Python 3.8 через `uv` — если `uv` есть, иначе предупреждение и пропуск. - В README (раздел «Установка» или новый короткий раздел в конце — - согласуй с задачей 01 через простоту: одна строка команды) — как запускать. - -## Готово, когда - -- Все изменённые shell-тесты зелёные; `tests/test_install.sh` тоже зелёный. -- Тесты не требуют установленного `stow`. -- `bash -n` и (если есть) `shellcheck` для изменённых скриптов чистые. -- Не коммитить: коммиты делает оркестратор. - -Отчёт: строки до/после, время до/после, принятые самостоятельно решения, -что осталось спорным. diff --git a/.scratch/proxy-client-simplify/spec.md b/.scratch/proxy-client-simplify/spec.md deleted file mode 100644 index fc038ff..0000000 --- a/.scratch/proxy-client-simplify/spec.md +++ /dev/null @@ -1,92 +0,0 @@ -# Упрощение proxy-client - -Status: ready-for-agent - -## Зачем - -`proxy-client` — личный клиент для владельца и нескольких друзей, а не -промышленное ПО. Большая часть кода и тестов — защита от сценариев, которые на -личной машине не случаются (повторное использование PID, подложный state.json, -zip-бомба в проверенном по SHA-256 официальном архиве, гонка `status` с -параллельным `start`). Эта защита стоит сотни строк кода и тестов и около -минуты прогона. Цель — оставить существенную сложность и убрать случайную. - -Принцип: **простой код, который честно делает своё дело и понятно сообщает об -ошибке**. Не защищаемся от самого пользователя и от его собственных файлов. - -## Что сохраняем (обязательное поведение) - -- Команды и их договор: `install [--replace]`, `status`, `env`, `run -- CMD`, - `start [-f FILE]`, `stop`; коды выхода (2 без подкоманды, 127 — команда не - найдена, 128+N — дочерний процесс убит сигналом N); восемь proxy-переменных. -- Отказ без запасного пути: выбран Xray, но он не запущен — ошибка, а не - переход на Hiddify. -- Машинный INI: строгий формат как сейчас (он маленький и понятный). -- Разбор VLESS: поддерживаемые TCP/REALITY и XHTTP/TLS, VLESS-шифрование - `mlkem768x25519plus`, неизвестные query-параметры отклоняются. -- Точная версия Xray 26.3.27 для `install` и `start` (сознательное решение). -- Установка Xray: официальный архив, проверка SHA-256 по `.dgst`, атомарная - замена через временный каталог рядом с назначением; `--replace` не пишет - через симлинк; неудачная установка не портит старый бинарник. -- `start` только на loopback-адресе; занятый порт — понятная ошибка. -- Не выводим UUID, ключи REALITY и `path` XHTTP в stdout/stderr. -- `client.json` и `state.json` — права 600 в каталоге 700 (в них секреты). -- Один управляемый Xray за раз: блокировка `flock` на время `start`. -- Остановка: Ctrl+C или `stop` гасят Xray и удаляют служебные файлы; если - супервизор убит (SIGKILL), `stop` добивает оставшийся Xray и чистит файлы. -- Python 3.8+ (у друзей старые дистрибутивы), только стандартная библиотека. - -## Что упрощаем - -1. **Проверка «тот ли процесс».** Вместо boot_id + starttime + `/proc/PID/exe` - + разрешения argv супервизора относительно его cwd: Xray — PID жив и - `/proc/PID/exe` совпадает с `xray_path`; супервизор — PID жив (и, если - дёшево, его cmdline упоминает `proxy-client`). Непохожий процесс считаем - мёртвым: сигнал не шлём, файлы считаем устаревшими и чистим. Состояние - «не удалось подтвердить, удалите файлы вручную» исчезает. Двойная проверка - под блокировкой при чистке устаревших файлов не нужна. state.json читаем - без строгой схемы: битый файл — понятная ошибка с путём. -2. **Скачивание Xray.** Убрать три уровня ограничений размера, сверку - Content-Length, строгую грамматику `.dgst`. Достаточно: скачать, найти в - `.dgst` строку `SHA2-256=`, сравнить без учёта регистра. Убрать - `O_EXCL`/0600 для кандидата внутри закрытого временного каталога и - требование «каталог установки внутри $HOME». -3. **`install` и работающий Xray.** Замена бинарника под работающим процессом - на Linux безопасна: не блокировать `install`, а напомнить перезапустить - `start`. Блокировку start на время install не брать. -4. **Разбор ссылки.** `extra` — обычный `json.loads` + проверка, что это - объект (без своих проверок повторных ключей и NaN: `xray run -test` - проверит). `allowInsecure`/`insecure` — принимаются с любым значением и - игнорируются; при истинном значении (`1`, `true`) — предупреждение. -5. **Установщик пакета `proxy-client/install.sh`.** Один способ — прямые - симлинки (без Stow). Не перезаписывать чужие файлы и сообщить обо всех - конфликтах сразу. Разрешить `~/.local` и `~/.config`, которые сами являются - ссылками. Блок в `~/.bashrc` — дописать, если нет маркера; без разбора - «повреждённых» блоков, поиска `.bash_custom` и точного текста `export PATH`. -6. **Подсказки об ошибках с файлами** называют файл полным путём и дают - готовую команду. Пример: «файл профиля /home/u/.config/proxy-client/default.url - доступен другим пользователям; выполните: chmod 600 /home/u/...». Если - профиль — симлинк, называть конечный файл (права ставятся на него). - -## Где живут тесты (согласовано с владельцем) - -1. Команда `proxy-client`: argv → код выхода, вывод, созданные файлы, - запущенные и остановленные процессы. -2. Разбор ссылки: `parse_vless` + `build_xray_config` (ссылка → JSON Xray) — - быстрые табличные тесты без запуска процессов. -3. Установка Xray: `command_install` с подставляемым скачивателем. -4. Функции Bash: через поддельный исполняемый файл в `PROXY_CLIENT_BIN`. -5. Установщик пакета: через временный HOME. - -Больше нигде. Запрещено: подмена внутренних функций через `__globals__` / -`mock.patch.dict`, проверки порядка внутренних вызовов, тесты тестовых -подделок, по нескольку полных циклов start/stop ради одного утверждения. - -## Задачи - -- `issues/01-cli.md` — CLI и Python-тесты. -- `issues/02-installer-shell.md` — установщик пакета, shell-тесты, запускалка. -- После 01 и 02 (делает оркестратор): привести разделы - `docs/specs/2026-07-15-proxy-client-design.md` про установку, жизненный - цикл, доставку, риски и проверку в соответствие с кодом; добавить в - `proxy-client/README.md` ссылку на спецификацию для тех, кто меняет код.