Merge PR #1: упростить proxy-client до личного инструмента

This commit was merged in pull request #1.
This commit is contained in:
ddmitry committed 2026-09-28 17:58:37 +03:00
commit fa9a93aa0c
10 files changed
+1565 -4428

No files matched your search

+159 -517
View File
@@ -2,577 +2,219 @@
Дата: 2026-07-15 Дата: 2026-07-15
Статус: основная реализация принята вручную; самостоятельный установщик Статус: реализовано; 2026-09-28 упрощено по принципу ниже
спроектирован 2026-07-21, но ещё не реализован
Точное поведение, форматы, сообщения и команды описаны в коде,
`proxy-client --help` и [`proxy-client/README.md`](../../proxy-client/README.md).
Здесь — то, чего по коду не видно: задача, границы, выбор и его причины, риски.
## Принцип: личный инструмент
`proxy-client` пользуются владелец и несколько друзей на своих машинах. Код
защищается от реальных ошибок и опасностей:
- скачанный Xray проверяется по SHA-256 и заменяется атомарно;
- секреты профиля не попадают в вывод, служебные файлы закрыты правами 600/700;
- локальный порт Xray слушает только loopback;
- чужие файлы не перезаписываются;
- сигнал уходит только процессам из `state.json`: Xray — если его
`/proc/PID/exe` совпадает с запущенным файлом, супервизору — если Xray
подтверждён и в командной строке супервизора есть `proxy-client`.
Пользователь и его собственные файлы — не противник. Сообщение об ошибке
называет файл полным путём и подсказывает готовую команду.
Новая защита появляется вместе со сценарием, который реально случается у
пользователя, и этот сценарий записывается рядом с ней. Так же проверяется и
прежний код: в 2026-09-28 убраны защиты без такого сценария — повторное
использование PID, подложный `state.json`, zip-бомба в проверенном архиве,
гонка `status` с параллельным `start`.
Тесты проверяют поведение через команду `proxy-client`, разбор ссылки
(`parse_vless` + `build_xray_config`), установку Xray с подставным
скачивателем, функции Bash с поддельным `PROXY_CLIENT_BIN` и установщик пакета
во временном HOME.
## Проблема ## Проблема
На удалённых Linux-серверах нет GUI-клиента Hiddify. Перед запуском Claude Code На удалённых Linux-серверах нет GUI-клиента Hiddify. Перед запуском Claude Code
или Codex приходится вручную запускать Xray и выставлять proxy-переменные. или Codex приходится вручную запускать Xray и выставлять proxy-переменные.
Переменные легко забыть, после чего агент стартует без прокси. Переменные легко забыть, и агент стартует без прокси.
Нужны привычные команды `pclaude` и `pcodex`, которые не запускают агента, Нужны привычные команды `pclaude` и `pcodex`, которые не запускают агента,
если явно настроенный прокси недоступен. Решение должно работать как часть этого если явно настроенный прокси недоступен. Решение работает и в составе этого
репозитория и отдельно от остальных dotfiles. репозитория, и отдельно от остальных dotfiles.
## Контекст ## Контекст
Существующий дизайн Прежний дизайн
[`pclaude` / `pcodex`](../superpowers/specs/2026-05-19-proxy-cli-launchers-design.md) [`pclaude` / `pcodex`](../superpowers/specs/2026-05-19-proxy-cli-launchers-design.md)
задаёт важный контракт: proxy-переменные видны только дочернему процессу, задаёт договор: proxy-переменные видны только дочернему процессу, аргументы и
аргументы и код возврата сохраняются, а недоступный прокси приводит к отказу код возврата сохраняются, недоступный прокси — отказ до запуска агента. Эта
до запуска агента. спецификация заменяет его Linux-часть; Windows-часть не меняется.
Эта спецификация заменяет Linux-часть прежнего дизайна в вопросах расположения Xray принимает конфигурационные файлы, а не `vless://`-ссылки. Его SOCKS-вход
функций, источника настроек и вывода `proxy_on`/`proxy_off`. Windows-часть принимает SOCKS и HTTP на одном порту, поэтому прежняя схема proxy-переменных
прежнего дизайна не меняется. подходит локальному Xray без второго порта. См.
Сейчас Bash-функции жёстко используют внешний Hiddify на
`hproxy.local:12334`. Принесённый прототип `scripts/setup-xray-proxy.sh`
умеет установить и запустить Xray из готового JSON, но смешивает установку и
запуск, не разбирает ссылки Hiddify и небезопасно ищет чужие процессы через
`pkill`.
Xray принимает конфигурационные файлы, а не `vless://`-ссылки. При этом его
SOCKS-вход принимает SOCKS и HTTP на одном порту, поэтому существующая схема
proxy-переменных совместима с локальным Xray без второго порта. См.
[командную строку Xray](https://xtls.github.io/en/document/command.html) и [командную строку Xray](https://xtls.github.io/en/document/command.html) и
[SOCKS-вход](https://xtls.github.io/en/config/inbounds/socks.html). [SOCKS-вход](https://xtls.github.io/en/config/inbounds/socks.html).
## Цели ## Цели
- Дать headless Linux-серверу управляемый пользовательский Xray без systemd и - Управляемый пользовательский Xray на Linux-сервере без systemd и root.
прав root. - Явные `pclaude` и `pcodex`, которые нельзя случайно запустить через другой
- Сохранить явные `pclaude` и `pcodex`, которые нельзя случайно запустить через прокси или без прокси.
другой прокси или без настроенного прокси. - Одна VLESS-ссылка Hiddify превращается в клиентский JSON Xray.
- Принимать одну точечную VLESS-ссылку Hiddify и преобразовывать её в клиентский - Внешний Hiddify работает, как раньше.
JSON Xray. - Машинный `NO_PROXY` настраивается без правки файлов репозитория.
- Сохранить совместимость Bash-функций с внешним Hiddify. - Каталог `proxy-client/` можно скопировать отдельно и поставить своим
- Позволить настраивать машинный `NO_PROXY` без правки файлов репозитория. установщиком, без остальных dotfiles и без Stow.
- Поддержать самостоятельную установку из отдельно скопированного каталога
`proxy-client/` без зависимости от общего установщика dotfiles и без
обязательного stow.
## Не входит в объём ## Не входит в объём
- Windows, PowerShell, Windows Codex launcher и VS Code Server. - Windows, PowerShell, Windows Codex launcher и VS Code Server.
- systemd, автозапуск после перезагрузки, sudo, firewall, TUN и изменение - systemd, автозапуск, sudo, firewall, TUN и системная маршрутизация.
системной маршрутизации. - Автоматический выбор между Xray и Hiddify или запасной прокси.
- Автоматический выбор между Xray и Hiddify или переход на запасной прокси. - Несколько одновременных профилей у одного Unix-пользователя.
- Одновременное управление несколькими профилями одного Unix-пользователя. - Импорт подписок или полного JSON-экспорта Hiddify.
- Универсальный импорт подписок или полного JSON-экспорта Hiddify. - Протоколы за пределами двух поддержанных семейств VLESS.
- Протоколы и сочетания за пределами двух явно поддержанных семейств. - pip, venv и сторонние Python-библиотеки.
- pip, venv и любые сторонние Python-библиотеки. - Сетевая изоляция голых `claude` и `codex`: намерение выражают команды с
- Сетевая изоляция голых `claude` и `codex`: как и раньше, гарантию намерения префиксом `p`.
дают явные команды с префиксом `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, процессы и запуск дочерних команд. `shell.bash` — единственный владелец `proxy_on`, `proxy_off`, `pclaude`,
Bash нужен только там, где необходимо изменить окружение текущей shell-сессии. `pcodex` и `myip`; в `bash/.bash_functions` их нет, чтобы не зависеть от
Это выбранный вариант. порядка `source`. Модуль сам конфиг не читает:
### Не создавать менеджер - `pclaude` и `pcodex` вызывают `proxy-client run -- COMMAND ARG...`;
- `proxy_on` берёт готовые переменные из `proxy-client env` и применяет их,
только если команда завершилась успешно; `eval` не используется;
- `proxy_off` очищает переменные в обоих регистрах.
Можно хранить готовые JSON и вручную запускать Xray в tmux. Вариант почти не `proxy_on`/`proxy_off` больше не запрашивают внешний IP: это замедляло
требует кода, но не решает основную боль: безопасный запуск `pclaude` и переключение. Для проверки есть `myip` и `proxy-client status`.
`pcodex` из точечной ссылки Hiddify.
### Доставка отдельного пакета ### Настройки
Рассмотрены четыре способа подключить отдельно скопированный каталог: Два файла в `~/.config/proxy-client/`:
- оставить только инструкции для stow и ручных симлинков — просто, но сохраняет - `config` — несекретный машинный INI: тип `xray` или `external`, имя, адрес,
легко забываемые шаги; порт, добавка к `NO_PROXY`. Если файла нет, используется прежний Hiddify на
- копировать файлы в домашний каталог — не требует сохранять исходный каталог, `hproxy.local:12334`.
но усложняет обновления и расходится с моделью dotfiles; - `default.url` — секретная VLESS-ссылка с правами 600; `start -f` берёт другой
- требовать stow — даёт привычное управление ссылками, но добавляет файл.
необязательную зависимость на удалённых серверах;
- дать пакету собственный установщик, который использует stow при наличии и
создаёт те же симлинки напрямую без него.
Выбран последний вариант. Он сохраняет один результат установки при разном Они разделены, потому что первый описывает политику машины, а второй — секрет,
составе машины и не требует второй копии обновляемых файлов. который меняется независимо. Выбран INI, а не TOML: `tomllib` появился только
в Python 3.11.
## Рекомендуемое решение ### Явный тип прокси
### Границы компонентов `xray` значит: нужен Xray, запущенный через `proxy-client start`. Если его нет,
агент не запускается. `external` значит: проверяется только доступность адреса
и порта. Ни один тип не переходит на другой сам — предсказуемость канала важнее
удобства.
`proxy-client` — один исполняемый файл на Python 3.8+ без внешних библиотек. Он `start` работает только при `type=xray` и loopback-адресе в конфиге. Поэтому
отвечает за установку Xray, запуск и остановку процесса, диагностику, нельзя поднять локальный Xray, оставив `pclaude` направленным во внешний
преобразование VLESS и запуск произвольной дочерней команды через прокси. Hiddify. `stop` от типа не зависит: после переключения на `external` старый
Нейтральное публичное имя описывает клиентскую прокси-подсистему целиком: Xray всё равно можно остановить.
внешний прокси и управляемый Xray. Название `xray` остаётся у конкретного
движка и значения `type = xray`, где оно действительно важно.
Переносимый `shell.bash` — единственный владелец `proxy_on`, `proxy_off`, Один порт на HTTP и SOCKS, по умолчанию `12334`; порт настраивается, чтобы
`pclaude`, `pcodex`, `myip` и внутренних proxy-помощников. При реализации они разные пользователи одной машины не мешали друг другу.
удаляются из `bash/.bash_functions`, чтобы результат не зависел от порядка
`source`. `update_hproxy` остаётся там как отдельная WSL-утилита управления
`/etc/hosts`, а не часть переносимого интерфейса прокси. Модуль не разбирает
машинный конфиг самостоятельно:
- `pclaude` и `pcodex` делегируют запуск форме ### VLESS-ссылки
`proxy-client run -- COMMAND ARG...`, где `--` отделяет параметры клиента;
- `proxy_on` получает от `proxy-client env` проверенный набор proxy-переменных и
применяет его к текущей Bash-сессии;
- `proxy_off` очищает верхний и нижний регистр proxy-переменных.
Общий `bash/.bash_functions` и модуль клиента не зависят друг от друга. В составе Поддержаны две семьи: TCP/RAW с REALITY и XHTTP с TLS. Неизвестный или
dotfiles `.bash_custom` подключает их двумя независимыми строками. Без повторный query-параметр — отказ: он может менять смысл соединения, и его
остальных dotfiles пользователь добавляет только один `source` модуля клиента в нужно добавить в разбор явно. Остальное проверяет `xray run -test`.
`.bashrc`. Для неинтерактивных сценариев и других оболочек остаётся прямой
`proxy-client run`.
`proxy-client env` сначала выполняет ту же проверку доступности, что и `run`, а Hiddify Manager добавляет `allowInsecure`/`insecure` даже к профилям с
затем печатает восемь строк `KEY=value`: HTTP, HTTPS, ALL и NO_PROXY в верхнем нормальным сертификатом
и нижнем регистре. `shell.bash` сначала целиком получает успешный ответ, затем ([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) эту
полной проверки экспортирует полученные значения через `printf -v`. `eval` не опцию удалил. Поэтому поля принимаются и игнорируются с предупреждением;
используется, частичного изменения окружения при ошибке нет. TLS-проверку задают `pcs` и `vcn`.
У `env` stdout зарезервирован только для восьми машинно-читаемых строк. Сводка перед запуском не показывает UUID, ключи REALITY и содержимое XHTTP
Предупреждения и ошибки идут в stderr; обычная безопасная сводка этой командой `path` (там бывает токен). Ссылка не передаётся аргументом командной строки.
не печатается.
Совместимость существующих 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; истинное значение даёт предупреждение, что
сертификат будет проверен обычным способом, а ложное не требует предупреждения.
Соединение после этого может успешно заработать или завершиться ошибкой
сертификата; отдельного раннего отказа нет.
### Установка Xray ### Установка Xray
`proxy-client install` — отдельная явная операция. Она устанавливает Xray в `proxy-client install` ставит в `~/.local/bin/xray` официальный архив одного
`~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем зафиксированного релиза (сейчас 26.3.27), проверяет SHA-256 по `.dgst` и
проверяет SHA-256. Поддерживается только зафиксированный релиз 26.3.27; заменяет файл атомарно. Официальный установщик Xray не подходит: он требует
параметра версии нет. Официальные релизы публикуют необходимые файлы `.dgst`: root и создаёт systemd-службы. `start` принимает только ту же версию и никогда
[релиз 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`.
### Жизненный цикл ### Жизненный цикл
CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `run`. Запуск `start` остаётся на переднем плане как небольшой Python-супервизор с одним
без подкоманды не устанавливает и не стартует ничего. дочерним Xray; ожидаемое место — панель tmux. Отдельный журнал и команда `logs`
не нужны: хватает истории tmux. Второй `start` отсекает блокировка `flock`.
`state.json` хранит PID супервизора и Xray, чтобы `stop` работал из любого
терминала и мог добить Xray, если супервизор убит.
`start` остаётся на переднем плане как небольшой Python-супервизор и занимает Перед запуском обязательны разбор ссылки, `xray run -test` и открытый порт
текущую консоль. Он запускает один дочерний Xray без демонизации и без новой после старта. Проверка внешнего IP — только диагностика: её сбой даёт
сессии процесса, ждёт его завершения и передаёт ему SIGINT, SIGTERM и SIGHUP. предупреждение.
Ожидаемый способ работы — отдельная панель tmux или другого мультиплексора.
Отсоединение от tmux не останавливает процесс, но закрытие панели или сессии
останавливает Xray. Отдельный постоянный журнал и команда `logs` не нужны:
достаточно истории tmux.
Супервизор на всё время работы удерживает неблокирующую эксклюзивную блокировку `run` никогда не запускает Xray, поэтому у `pclaude` и `pcodex` нет скрытого
`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` не имеют скрытого автозапуска.
### Доставка ### Доставка
Компоненты образуют отдельный stow-совместимый каталог `proxy-client/`. Он Каталог `proxy-client/` раскладывается в `$HOME` ссылками: CLI в
доставляет CLI, Bash-модуль и три шаблона: машинного INI для внешнего прокси, `~/.local/bin/`, `shell.bash` и `examples/` в `~/.config/proxy-client/`.
машинного INI для Xray и файла VLESS-ссылки. Машинный `config` и `default.url` пользователь создаёт сам; в Git они не
Реальные пользовательские файлы не управляются stow и не входят в Git. входят.
CLI раскладывается как `~/.local/bin/proxy-client`. Машинный конфиг, профиль,
модуль `shell.bash` и каталог `examples/` находятся вместе в
`~/.config/proxy-client/`. Шаблоны не подменяют настоящие пользовательские
файлы.
В корне этого каталога находятся самостоятельный `README.md` и пакетный - Отдельно: `proxy-client/install.sh` ставит эти три ссылки и по умолчанию
`install.sh`. Оба файла относятся к исходному каталогу и исключаются из дописывает блок в `~/.bashrc`. Чужие файлы не трогает. Режим прокси, Xray и
раскладки stow. README остаётся рядом с кодом при выборочном клонировании или сеть не настраивает — это делают отдельные команды по README.
копировании каталога. Корневой README репозитория только кратко описывает - В составе dotfiles: корневой `install.sh` подключает тот же пакет через Stow,
подсистему и ссылается на него. а `.bash_custom` загружает `shell.bash`.
Пакетный установщик связывает отдельно скопированный каталог с домашним ## Риски
каталогом и не зависит от положения или наличия остальных dotfiles. Если stow
доступен, установщик использует его; если нет — создаёт тот же набор симлинков
напрямую. Выбранный механизм явно показывается пользователю, но не меняет
результат. Исходный каталог должен оставаться на прежнем пути всё время
использования ссылок.
Установщик управляет только тремя публичными артефактами: CLI, `shell.bash` и - Формат ссылок Hiddify расширяется. Строгий отказ на неизвестном параметре
каталогом шаблонов. Машинный `config` и закрытый `default.url` не входят в лучше частично неверного JSON, но новые ссылки могут потребовать правки
раскладку stow, никогда не создаются, не заменяются и не связываются разбора.
установщиком. Их наличие в исходном каталоге также не должно расширять набор - Открытый порт не доказывает, что маршрут работает. Проверка внешнего IP
управляемых путей. отделена от обязательных проверок.
- Loopback без аутентификации открыт другим пользователям того же узла.
Домашние `~/.local/bin` и `~/.config/proxy-client` остаются настоящими Пользователи машины считаются доверенными.
каталогами. Stow не должен сворачивать их или их родителей в одну ссылку на - `NO_PROXY` программы понимают по-разному, особенно CIDR. README советует
исходное дерево: ссылками становятся только три перечисленных артефакта. Это точные IP и домены.
одновременно сохраняет место для машинных файлов и делает результат stow - Ссылки установки зависят от пути исходного каталога: переносить его нельзя.
совпадающим с прямой раскладкой через `ln -s`. - Поддержка РЕД ОС, Astra Linux и Alt Linux заявляется только после проверки
версии Python и прогона `tests/test_proxy_client_full.sh` на них.
Перед записью установщик проверяет весь набор назначений. Правильная ссылка на - Шаблоны в `examples/` содержат только вымышленные значения; настоящие ссылки,
текущий исходный каталог считается уже установленной; любой другой файл, UUID и ключи в репозиторий не попадают.
каталог или симлинк считается конфликтом. При конфликте установщик ничего не
меняет и сообщает все пути, которые пользователь должен разобрать вручную.
Принудительной замены и автоматических резервных копий нет.
По умолчанию установщик идемпотентно подключает 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 и ключи никогда не входят в репозиторий.
+10 -32
View File
@@ -5,21 +5,17 @@ _proxy_cli() {
"${PROXY_CLIENT_BIN:-proxy-client}" "$@" "${PROXY_CLIENT_BIN:-proxy-client}" "$@"
} }
_proxy_apply_env_file() { # Применяет вывод "proxy-client env" целиком или не применяет ничего.
local output_file="$1" line key value _proxy_apply_env() {
local line key
local -A proxy_values=() 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 if [[ "$line" != *=* ]]; then
echo "Ошибка: proxy-client env вернул строку без '='" >&2 echo "Ошибка: proxy-client env вернул строку без '='" >&2
return 1 return 1
fi fi
key="${line%%=*}" key="${line%%=*}"
value="${line#*=}"
case "$key" in case "$key" in
HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|http_proxy|https_proxy|all_proxy|no_proxy) ;; 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 return 1
;; ;;
esac esac
proxy_values["$key"]="$value" proxy_values["$key"]="${line#*=}"
done < "$output_file" done <<< "$1"
for key in "${allowed_keys[@]}"; do for key in "${!proxy_values[@]}"; do
if [[ -n "${proxy_values[$key]+set}" ]]; then export "$key=${proxy_values[$key]}"
printf -v "$key" '%s' "${proxy_values[$key]}"
export "$key"
fi
done done
} }
proxy_on() { proxy_on() {
local output_file old_umask local output
old_umask="$(umask)" if ! output="$(_proxy_cli env)" || ! _proxy_apply_env "$output"; then
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"
echo "Ошибка: прокси не включён" >&2 echo "Ошибка: прокси не включён" >&2
return 1 return 1
fi fi
if ! _proxy_apply_env_file "$output_file"; then
rm -f "$output_file"
echo "Ошибка: прокси не включён" >&2
return 1
fi
rm -f "$output_file"
echo "Прокси включён" echo "Прокси включён"
} }
File diff suppressed because it is too large. Load diff
+49 -21
View File
@@ -19,13 +19,16 @@ CLI требует Python 3.8+ и использует только станда
~/src/proxy-client/install.sh ~/src/proxy-client/install.sh
``` ```
При наличии Stow установщик использует его, иначе создаёт те же три симлинка Установщик создаёт три ссылки на исходный каталог: CLI, Bash-модуль и каталог
напрямую: CLI, Bash-модуль и каталог шаблонов. Исходный каталог после установки шаблонов. Поэтому исходный каталог после установки нельзя перемещать. Если
нельзя перемещать: домашние ссылки зависят от его постоянного пути. какой-то из путей уже занят чужим файлом или ссылкой, установщик ничего не
меняет и перечисляет все такие пути. Повторный запуск безопасен.
По умолчанию установщик добавляет в `~/.bashrc` помеченный блок для По умолчанию установщик дописывает в `~/.bashrc` блок между строками
`~/.local/bin` и Bash-модуля. После установки откройте новый Bash или примените `# >>> proxy-client >>>` и `# <<< proxy-client <<<`: он добавляет
блок в текущей сессии: `~/.local/bin` в `PATH` и подключает Bash-модуль. Если блок уже есть, файл не
меняется. После установки откройте новый Bash или примените блок в текущей
сессии:
```bash ```bash
source ~/.bashrc source ~/.bashrc
@@ -253,8 +256,13 @@ proxy-client stop
плане. Systemd, фоновый автозапуск и скрытый запуск из `run` не используются. плане. Systemd, фоновый автозапуск и скрытый запуск из `run` не используются.
Один loopback-порт принимает HTTP CONNECT и SOCKS5. Один loopback-порт принимает HTTP CONNECT и SOCKS5.
`stop` завершает управляемые процессы и удаляет созданные служебные файлы. Он Остановить Xray можно двумя способами: `Ctrl+C` в окне `start` или
работает и после изменения машинного типа на `external`. `proxy-client stop` из любого терминала. Оба гасят Xray и удаляют служебные
файлы. Если процесс `start` был убит (например, закрыли окно tmux), Xray может
остаться работать: `status` и `env` тогда сообщат об ошибке, а `stop` добьёт его
и уберёт файлы. `stop` работает и после изменения машинного типа на `external`.
Второй `start`, пока первый работает, завершится ошибкой.
## Диагностика ## Диагностика
@@ -343,7 +351,8 @@ source ~/.config/proxy-client/shell.bash
Поддерживаются VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные, Поддерживаются VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные,
повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN
`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей. `http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом, его содержимое
проверяет `xray run -test`.
Кроме `encryption=none`, принимается VLESS-шифрование Кроме `encryption=none`, принимается VLESS-шифрование
`encryption=mlkem768x25519plus.…` из новых ссылок Hiddify; остальную часть `encryption=mlkem768x25519plus.…` из новых ссылок Hiddify; остальную часть
@@ -351,9 +360,9 @@ source ~/.config/proxy-client/shell.bash
`flow=xtls-rprx-vision`. Косая черта перед параметрами (`…:443/?type=…`) `flow=xtls-rprx-vision`. Косая черта перед параметрами (`…:443/?type=…`)
допустима, другой путь в ссылке — нет. допустима, другой путь в ссылке — нет.
Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и Xray 26.3.27 использует `pcs` и `vcn`. Устаревшие `allowInsecure` и `insecure`
`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но принимаются с любым значением, но не переносятся: при значении `1` или `true`
обычная проверка сертификата остаётся включённой. выводится предупреждение, а сертификат проверяется обычным способом.
Последняя версия Xray, где `allowInsecure` ещё применяется, — Последняя версия Xray, где `allowInsecure` ещё применяется, —
[25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция [25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция
@@ -366,14 +375,18 @@ Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allow
`x86_64`/`amd64` используется `Xray-linux-64.zip`, для `aarch64`/`arm64` — `x86_64`/`amd64` используется `Xray-linux-64.zip`, для `aarch64`/`arm64` —
`Xray-linux-arm64-v8a.zip`; остальные архитектуры отклоняются. `Xray-linux-arm64-v8a.zip`; остальные архитектуры отклоняются.
CLI скачивает официальный архив и файл `.dgst`, проверяет SHA-256, ограничения CLI скачивает официальный архив и файл `.dgst`, сверяет SHA-256 и фактическую
размера и фактическую версию бинарника, а затем атомарно заменяет назначение. версию бинарника, а затем атомарно заменяет назначение. При любой ошибке старый
Корректная уже установленная версия даёт no-op без сети. Другой файл или симлинк файл остаётся на месте. Если нужная версия уже установлена, сеть не нужна и
не заменяется без `--replace`; каталог не заменяется никогда. ничего не меняется. Другой файл или симлинк не заменяется без `--replace`
(симлинк заменяется сам, файл по ссылке не трогается); каталог не заменяется
никогда. Если после установки `PATH` находит другой `xray`, `install` сообщает об
ошибке и подсказывает команду.
Устанавливать можно и при работающем Xray: он продолжит работу со старым файлом,
а `install` напомнит перезапустить `start`.
`start` использует первый `xray` из `PATH` и принимает только версию 26.3.27. `start` использует первый `xray` из `PATH` и принимает только версию 26.3.27.
Если `PATH` выбирает другой бинарник, команда сообщает об ошибке. Во время
живого или неподтверждённого управляемого процесса установка запрещена.
### Файлы и безопасность ### Файлы и безопасность
@@ -385,6 +398,21 @@ CLI скачивает официальный архив и файл `.dgst`, п
| `$XDG_STATE_HOME/proxy-client/` или `~/.local/state/proxy-client/` | Состояние и сгенерированный JSON | | `$XDG_STATE_HOME/proxy-client/` или `~/.local/state/proxy-client/` | Состояние и сгенерированный JSON |
Сводка скрывает UUID и ключи, а для XHTTP показывает только длину `path`. Сводка скрывает UUID и ключи, а для XHTTP показывает только длину `path`.
Служебный каталог закрыт для других пользователей и очищается после штатной Если файл профиля доступен другим пользователям, `start` не запустится и
остановки Xray. Сбой определения внешнего IP показывается как предупреждение и покажет готовую команду `chmod 600` для конечного файла (у симлинка — для файла,
не останавливает уже готовый процесс. на который он указывает). Служебный каталог закрыт для других пользователей
(`700`, файлы в нём — `600`) и очищается после остановки Xray. Сбой
определения внешнего IP показывается как предупреждение и не останавливает уже
готовый процесс.
## Изменение кода
Перед правкой proxy-client прочитайте
[спецификацию](../docs/specs/2026-07-15-proxy-client-design.md): там сказано,
от чего клиент защищается, от чего нет и почему, и где живут тесты.
Все тесты пакета (из корня dotfiles):
```bash
tests/test_proxy_client_full.sh
```
+32 -155
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Ставит proxy-client в домашний каталог: три ссылки на этот каталог и
# (по умолчанию) блок в ~/.bashrc. Повторный запуск ничего не меняет.
set -euo pipefail set -euo pipefail
PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
STOW_BIN="${STOW_BIN:-stow}"
SETUP_SHELL=1 SETUP_SHELL=1
usage() { usage() {
@@ -15,9 +16,7 @@ EOF
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--no-shell-setup) --no-shell-setup) SETUP_SHELL=0 ;;
SETUP_SHELL=0
;;
-h|--help) -h|--help)
usage usage
exit 0 exit 0
@@ -31,180 +30,58 @@ while [ "$#" -gt 0 ]; do
shift shift
done done
SOURCES=( # Машинные config и default.url сюда не входят: они живут только в $HOME.
"$PACKAGE_DIR/.local/bin/proxy-client" LINKS=(
"$PACKAGE_DIR/.config/proxy-client/shell.bash" .local/bin/proxy-client
"$PACKAGE_DIR/.config/proxy-client/examples" .config/proxy-client/shell.bash
) .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$'
) )
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=() conflicts=()
missing_targets=0 for link in "${LINKS[@]}"; do
stow_ignore_args=() target="$HOME/$link"
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]}"
if [ -e "$target" ] || [ -L "$target" ]; then if [ -e "$target" ] || [ -L "$target" ]; then
if [ ! -L "$target" ] \ if [ "$(readlink -f "$target" 2>/dev/null)" != "$PACKAGE_DIR/$link" ]; then
|| [ "$(readlink -f "$target" 2>/dev/null || true)" != "$source_path" ]; then
conflicts+=("$target") conflicts+=("$target")
else
stow_ignore_args+=("--ignore=${STOW_IGNORE_PATTERNS[$index]}")
fi fi
else
missing_targets=$((missing_targets + 1))
fi fi
done 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 if [ "${#conflicts[@]}" -gt 0 ]; then
echo "Ошибка: найдены конфликтующие пути:" >&2 echo "Ошибка: пути уже заняты, установка не выполнена:" >&2
printf ' %s\n' "${conflicts[@]}" >&2 printf ' %s\n' "${conflicts[@]}" >&2
echo "Разберите их вручную и повторите установку." >&2 echo "Удалите или переименуйте их и повторите установку." >&2
exit 1 exit 1
fi fi
mkdir -p "$HOME/.local/bin" "$HOME/.config/proxy-client" mkdir -p "$HOME/.local/bin" "$HOME/.config/proxy-client"
for link in "${LINKS[@]}"; do
if command -v "$STOW_BIN" >/dev/null 2>&1; then [ -e "$HOME/$link" ] || ln -s "$PACKAGE_DIR/$link" "$HOME/$link"
echo "Установка с помощью Stow."
if [ "$missing_targets" -gt 0 ]; then
"$STOW_BIN" --dir="$(dirname "$PACKAGE_DIR")" --target="$HOME" \
"${stow_ignore_args[@]}" \
"$(basename "$PACKAGE_DIR")"
fi
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 done
fi
"$HOME/.local/bin/proxy-client" --help >/dev/null SHELL_BLOCK_START='# >>> proxy-client >>>'
bash -n "$HOME/.config/proxy-client/shell.bash" 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
if ! grep -Fqx "$SHELL_BLOCK_START" "$HOME/.bashrc" 2>/dev/null; then
cat >> "$HOME/.bashrc" <<EOF
if [ "$SHELL_SETUP_NEEDED" -eq 1 ]; then $SHELL_BLOCK_START
cat >> "$HOME/.bashrc" <<'EOF' case ":\$PATH:" in
*":\$HOME/.local/bin:"*) ;;
# >>> proxy-client >>> *) export PATH="\$HOME/.local/bin:\$PATH" ;;
EOF
if [ "$SHELL_PATH_NEEDED" -eq 1 ]; then
cat >> "$HOME/.bashrc" <<'EOF'
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*)
export PATH="$HOME/.local/bin:$PATH"
;;
esac esac
EOF [ -f "\$HOME/.config/proxy-client/shell.bash" ] && source "\$HOME/.config/proxy-client/shell.bash"
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'
# <<< proxy-client <<< # <<< proxy-client <<<
EOF EOF
elif [ "$SETUP_SHELL" -eq 0 ]; then echo "В ~/.bashrc добавлен блок proxy-client."
echo "Настройка Bash пропущена (--no-shell-setup)."
fi fi
if [ "$SETUP_SHELL" -eq 1 ]; then
RELOAD_COMMAND='source ~/.bashrc' RELOAD_COMMAND='source ~/.bashrc'
else
RELOAD_COMMAND='export PATH="$HOME/.local/bin:$PATH"; source ~/.config/proxy-client/shell.bash'
fi fi
cat <<EOF cat <<EOF
+416 -1157
View File
File diff suppressed because it is too large. Load diff
+10 -12
View File
@@ -1,19 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Все тесты proxy-client: Python-тесты (текущий python3 и, если есть uv,
# Python 3.8 — минимальная поддерживаемая версия) и shell-тесты.
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$(dirname "${BASH_SOURCE[0]}")/.."
cd "$REPO_ROOT"
if ! command -v uv >/dev/null 2>&1; then
echo "Ошибка: для полной проверки требуется uv" >&2
exit 1
fi
export PYTHONDONTWRITEBYTECODE=1 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_proxy_launchers.sh
bash tests/test_chatgpt_proxy_launcher.sh
bash tests/test_proxy_client_install.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
+143 -367
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Тесты пакетного установщика proxy-client/install.sh во временном HOME.
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -10,393 +11,168 @@ fail() {
exit 1 exit 1
} }
ok() {
echo "ok - $*"
}
assert_link_points_to() { assert_link_points_to() {
local link_path="$1" expected_path="$2" actual_path local link_path="$1" expected_path="$2"
[ -L "$link_path" ] || fail "Expected symlink: $link_path" [ -L "$link_path" ] || fail "нет ссылки $link_path"
actual_path="$(readlink -f "$link_path")" [ "$(readlink -f "$link_path")" = "$expected_path" ] \
[ "$actual_path" = "$expected_path" ] \ || fail "$link_path указывает не на $expected_path"
|| fail "$link_path points to $actual_path, expected $expected_path"
} }
assert_not_exists() { assert_not_exists() {
local path="$1" [ ! -e "$1" ] && [ ! -L "$1" ] || fail "путь не должен существовать: $1"
[ ! -e "$path" ] && [ ! -L "$path" ] || fail "Path should not exist: $path"
} }
setup_package() { assert_package_links() {
local fixture_root="$1" package_dir="$fixture_root/source with spaces/proxy-client" local package_dir="$1" home_dir="$2" link
mkdir -p \ for link in .local/bin/proxy-client .config/proxy-client/shell.bash \
"$package_dir/.local/bin" \ .config/proxy-client/examples; do
"$package_dir/.config/proxy-client" assert_link_points_to "$home_dir/$link" "$package_dir/$link"
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"
done done
} }
test_accepts_existing_links_when_method_changes() { # Копия пакета в пути с пробелами и машинными файлами, которые установщик
local fixture_root package_dir home_dir # не должен трогать. Печатает путь к пакету; HOME создаётся рядом.
fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" new_fixture() {
package_dir="$(setup_package "$fixture_root")" local fixture package_dir
home_dir="$fixture_root/home" fixture="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")"
mkdir -p "$home_dir" package_dir="$fixture/source with spaces/proxy-client"
mkdir -p "$package_dir" "$fixture/home"
run_package_install "$package_dir" "$home_dir" missing-stow \ cp -a "$REPO_ROOT/proxy-client/." "$package_dir/"
--no-shell-setup >/dev/null printf 'MACHINE_CONFIG_SENTINEL\n' > "$package_dir/.config/proxy-client/config"
run_package_install "$package_dir" "$home_dir" stow \ printf 'PRIVATE_PROFILE_SENTINEL\n' > "$package_dir/.config/proxy-client/default.url"
--no-shell-setup >/dev/null printf '%s\n' "$fixture"
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"
} }
test_stow_completes_partial_correct_installation() { run_install() {
local fixture_root package_dir home_dir local fixture="$1"
fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" shift
package_dir="$(setup_package "$fixture_root")" (cd "$TEST_TMP_ROOT" && HOME="$fixture/home" \
home_dir="$fixture_root/home" "$fixture/source with spaces/proxy-client/install.sh" "$@")
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"
} }
test_does_not_duplicate_full_dotfiles_shell_setup() { home_snapshot() {
local fixture_root package_dir home_dir source_count find "$1" -printf '%p %y %l %s %T@\n' | sort
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"
} }
test_rejects_damaged_managed_bash_block_before_changes() { test_fresh_install() {
local fixture_root package_dir home_dir output local fixture home_dir package_dir output
fixture_root="$(mktemp -d "$TEST_TMP_ROOT/fixture.XXXXXX")" fixture="$(new_fixture)"
package_dir="$(setup_package "$fixture_root")" home_dir="$fixture/home"
home_dir="$fixture_root/home" package_dir="$fixture/source with spaces/proxy-client"
mkdir -p "$home_dir"
printf '%s\n' '# >>> proxy-client >>>' > "$home_dir/.bashrc"
if output="$( run_install "$fixture" >/dev/null
run_package_install "$package_dir" "$home_dir" missing-stow 2>&1
)"; then assert_package_links "$package_dir" "$home_dir"
fail "Expected damaged managed Bash block to be rejected" [ -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 fi
printf '%s' "$output" | grep -Fq "$home_dir/.bashrc" \ printf '%s' "$output" | grep -Fq "$home_dir/.local/bin/proxy-client" \
|| fail "Expected ~/.bashrc conflict in output" || fail "не назван конфликт CLI"
[ "$(wc -l < "$home_dir/.bashrc")" -eq 1 ] \ printf '%s' "$output" | grep -Fq "$home_dir/.config/proxy-client/examples" \
|| fail "Expected damaged Bash block to stay unchanged" || fail "не названа битая ссылка"
assert_not_exists "$home_dir/.local" grep -Fqx KEEP_CLI "$home_dir/.local/bin/proxy-client" \
assert_not_exists "$home_dir/.config" || fail "чужой CLI изменён"
} [ "$(readlink "$home_dir/.config/proxy-client/examples")" = "$fixture/missing" ] \
|| fail "битая ссылка изменена"
test_does_not_duplicate_existing_local_bin_path() { assert_not_exists "$home_dir/.config/proxy-client/shell.bash"
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"
assert_not_exists "$home_dir/.bashrc" assert_not_exists "$home_dir/.bashrc"
ok "все конфликты названы сразу, частичной установки нет"
} }
main() { test_keeps_machine_files() {
test_installs_same_public_artifacts_without_stow local fixture home_dir
test_configures_bash_once_by_default fixture="$(new_fixture)"
test_installs_same_public_artifacts_with_stow home_dir="$fixture/home"
test_rejects_linked_parent_directories_before_changes mkdir -p "$home_dir/.config/proxy-client"
test_reports_all_target_conflicts_without_partial_install printf 'HOME_CONFIG\n' > "$home_dir/.config/proxy-client/config"
test_rejects_broken_link_without_creating_other_targets printf 'HOME_PROFILE\n' > "$home_dir/.config/proxy-client/default.url"
test_preserves_machine_files_with_both_methods
test_accepts_existing_links_when_method_changes run_install "$fixture" --no-shell-setup >/dev/null
test_stow_completes_partial_correct_installation
test_does_not_duplicate_full_dotfiles_shell_setup [ ! -L "$home_dir/.config/proxy-client/config" ] || fail "config стал ссылкой"
test_rejects_damaged_managed_bash_block_before_changes [ ! -L "$home_dir/.config/proxy-client/default.url" ] \
test_does_not_duplicate_existing_local_bin_path || fail "default.url стал ссылкой"
test_rejects_incomplete_source_before_changes grep -Fqx HOME_CONFIG "$home_dir/.config/proxy-client/config" \
echo "All standalone proxy-client install tests passed" || 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"
File diff suppressed because it is too large. Load diff
+85 -131
View File
@@ -1,12 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Тесты Bash-функций proxy-client: вместо настоящего CLI — поддельный
# исполняемый файл в PROXY_CLIENT_BIN.
# Строчные proxy-переменные задаёт proxy_on, shellcheck этого не видит.
# shellcheck disable=SC2154
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FUNCTIONS_FILE="$REPO_ROOT/bash/.bash_functions" FUNCTIONS_FILE="$REPO_ROOT/bash/.bash_functions"
CUSTOM_FILE="$REPO_ROOT/bash/.bash_custom" CUSTOM_FILE="$REPO_ROOT/bash/.bash_custom"
SHELL_MODULE="$REPO_ROOT/proxy-client/.config/proxy-client/shell.bash" 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() { fail() {
echo "FAIL: $*" >&2 echo "FAIL: $*" >&2
@@ -14,177 +21,124 @@ fail() {
} }
ok() { ok() {
pass_count=$((pass_count + 1))
echo "ok - $*" echo "ok - $*"
} }
assert_eq() { assert_eq() {
local expected="$1" actual="$2" message="$3" [ "$1" = "$2" ] || fail "$3 (ожидалось '$1', получено '$2')"
[ "$expected" = "$actual" ] || fail "$message (ожидалось '$expected', получено '$actual')"
} }
valid_env() { # Поддельный CLI: на "env" печатает $FAKE_ENV_OUTPUT, на остальное — свои
cat <<'EOF' # аргументы по одному в строке; завершается с кодом $FAKE_RC.
HTTP_PROXY=http://proxy.test:1234 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 HTTPS_PROXY=http://proxy.test:1234
ALL_PROXY=socks5://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 http_proxy=http://proxy.test:1234
https_proxy=http://proxy.test:1234 https_proxy=http://proxy.test:1234
all_proxy=socks5://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'
EOF
}
assert_five_proxy_commands() { # shellcheck source=/dev/null
local command_name source "$SHELL_MODULE"
for command_name in proxy_on proxy_off pclaude pcodex myip; do
declare -F "$command_name" >/dev/null || fail "нет функции $command_name" # 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 done
} )
ok "proxy_on применяет восемь переменных"
# Самостоятельный модуль предоставляет весь публичный Bash-интерфейс. # Ошибка CLI или непонятный вывод не меняют окружение ни на одну переменную.
for case_spec in "1|$VALID_ENV" "0|$VALID_ENV
EXTRA_PROXY=bad" "0|BROKEN-LINE"; do
( (
source "$SHELL_MODULE" for key in "${PROXY_KEYS[@]}"; do export "$key=old"; done
assert_five_proxy_commands if FAKE_RC="${case_spec%%|*}" FAKE_ENV_OUTPUT="${case_spec#*|}" \
) && ok "самостоятельное подключение предоставляет пять proxy-команд" proxy_on >/dev/null 2>&1; then
fail "proxy_on принял ошибочный ответ: ${case_spec#*|}"
# Обычная .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
(
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 принял неверный вывод"
fi fi
assert_eq old-http "$HTTP_PROXY" "HTTP_PROXY изменился при ошибке" for key in "${PROXY_KEYS[@]}"; do
assert_eq old-https "$HTTPS_PROXY" "HTTPS_PROXY изменился при ошибке" assert_eq old "${!key}" "$key изменился после ошибки"
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 "атомарность нарушена для неверного вывода"
done done
ok "неизвестная env-строка отклоняется без частичного изменения окружения" )
done
ok "неуспешный env не меняет окружение"
# Даже полный stdout не применяется, если CLI завершился с ошибкой. # proxy_off очищает оба регистра.
( (
source "$SHELL_MODULE" for key in "${PROXY_KEYS[@]}"; do export "$key=x"; done
export HTTP_PROXY=old HTTPS_PROXY=old ALL_PROXY=old NO_PROXY=old proxy_off >/dev/null
export http_proxy=old https_proxy=old all_proxy=old no_proxy=old for key in "${PROXY_KEYS[@]}"; do
_proxy_cli() { [ -z "${!key+set}" ] || fail "$key не очищен"
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"
done done
) && ok "stdout неуспешного env не изменяет окружение" )
ok "proxy_off очищает восемь переменных"
# Обёртки передают CLI команду, аргументы и код возврата без искажений. # Обёртки передают CLI команду, аргументы и код возврата без искажений.
( (
source "$SHELL_MODULE" export FAKE_RC=42
_proxy_cli() {
printf '<%s>\n' "$@"
return 42
}
set +e set +e
output="$(pclaude "a b" -- --flag)" output="$(pclaude "a b" -- --flag)"
rc=$? rc=$?
set -e set -e
assert_eq 42 "$rc" "pclaude не сохранил код возврата" assert_eq 42 "$rc" "pclaude не сохранил код возврата"
assert_eq $'<run>\n<-->\n<claude>\n<a b>\n<-->\n<--flag>' "$output" "pclaude исказил аргументы" assert_eq $'<run>\n<-->\n<claude>\n<a b>\n<-->\n<--flag>' "$output" \
"pclaude исказил аргументы"
set +e set +e
output="$(pcodex --search)" output="$(pcodex --search)"
rc=$? rc=$?
set -e set -e
assert_eq 42 "$rc" "pcodex не сохранил код возврата" assert_eq 42 "$rc" "pcodex не сохранил код возврата"
assert_eq $'<run>\n<-->\n<codex>\n<--search>' "$output" "pcodex исказил аргументы" assert_eq $'<run>\n<-->\n<codex>\n<--search>' "$output" \
) && ok "pclaude и pcodex делегируют run и сохраняют аргументы/код" "pcodex исказил аргументы"
)
ok "pclaude и pcodex передают аргументы и код выхода"
# proxy_off очищает оба регистра и не смешивает действие с диагностикой. # .bash_custom подключает и общие функции, и proxy-модуль.
( (
source "$SHELL_MODULE" home="$TEST_ROOT/home"
export HTTP_PROXY=x HTTPS_PROXY=x ALL_PROXY=x NO_PROXY=x mkdir -p "$home/.config/proxy-client"
export http_proxy=x https_proxy=x all_proxy=x no_proxy=x ln -s "$CUSTOM_FILE" "$home/.bash_custom"
curl() { fail "proxy_off не должен вызывать curl"; } ln -s "$FUNCTIONS_FILE" "$home/.bash_functions"
proxy_off >/dev/null ln -s "$SHELL_MODULE" "$home/.config/proxy-client/shell.bash"
for key in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \ output="$(
http_proxy https_proxy all_proxy no_proxy; do HOME="$home" bash --noprofile --norc -ic '
[ -z "${!key+x}" ] || fail "$key не очищен" source "$HOME/.bash_custom"
done declare -F proxy_on update_hproxy >/dev/null && printf ready
) && ok "proxy_off очищает восемь переменных без запроса IP" ' 2>/dev/null
)"
# Общий файл больше не владеет proxy-интерфейсом, но сохраняет update_hproxy. assert_eq ready "$output" ".bash_custom не подключил модули"
( )
source "$FUNCTIONS_FILE" ok ".bash_custom подключает общие функции и proxy-модуль"
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 сохранён"
if command -v shellcheck >/dev/null 2>&1; then 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 нашёл проблемы" || fail "shellcheck нашёл проблемы"
ok "shellcheck чист" ok "shellcheck чист"
else else
echo "skip - shellcheck не установлен" echo "skip - shellcheck не установлен"
fi fi
echo "PASS: $pass_count проверок пройдено" echo "PASS: тесты Bash-функций proxy-client"