feat(xray-proxy): добавлен управляемый прокси для CLI

Зачем:
- отделить переносимый прокси-интерфейс от общих Bash-функций
- запускать агентские CLI через явный внешний или управляемый прокси

Что:
- добавлен stow-пакет xray-proxy с CLI, Bash-модулем и примерами
- реализованы строгий разбор VLESS и управляемый запуск Xray
- добавлены проверки процесса, порта, сигналов и отсутствия утечек
- сохранены спецификация и очередь следующих тикетов

Проверка:
- python3 tests/test_xray_proxy.py — 22/22
- python3 tests/test_xray_proxy_managed.py — 16/16
- bash tests/test_proxy_launchers.sh — 9/9
- bash tests/test_install.sh
- bash -n, Python 3.8 grammar, git diff --check
This commit is contained in:
ddadmin committed 2026-07-20 00:00:59 +03:00
1 parent fdf744e578
commit e69a4bb7df
20 files changed
+3224 -170

No files matched your search

@@ -0,0 +1,68 @@
# 01 — Внешний прокси и переносимый Bash-интерфейс
**What to build:** Самостоятельный пользовательский пакет прокси, через который
можно безопасно запускать команды с уже работающим внешним прокси, например
Hiddify. Привычные Bash-команды переходят на единый CLI-контракт, но прежний
сценарий без машинного конфига продолжает работать.
**Blocked by:** None — can start immediately.
**Status:** done
- [x] CLI работает на Python 3.8+ без сторонних библиотек, ничего не устанавливает
и не запускает без явной подкоманды и выдаёт понятную ошибку на более старом
Python.
- [x] Машинный INI явно различает типы `external` и `xray`, проверяет имя, адрес,
порт и добавку к `NO_PROXY`; отсутствие файла сохраняет внешний прокси
`hproxy.local:12334` без неявного переключения на другой тип.
- [x] Канонический машинный INI содержит единственную секцию `[proxy]` и ровно
по одному ключу `type`, `name`, `address`, `port`, `no_proxy_extra`:
```ini
[proxy]
type = external
name = Hiddify
address = hproxy.local
port = 12334
no_proxy_extra = .example.internal,git.example.internal
```
Все ключи обязательны при наличии файла; `no_proxy_extra` может быть пустым.
Неизвестные секции и ключи, повторы, пропуски, многострочные или содержащие
NUL значения, пустые имя или адрес и порт вне диапазона 1–65535 отклоняются.
Добавки к `NO_PROXY` задаются через запятую, окружающие пробелы удаляются,
пустые элементы отклоняются.
- [x] `address` принимает IPv4, IPv6 без квадратных скобок либо ASCII DNS-имя
из корректных непустых меток; схема URL, встроенный порт, скобки, zone ID,
пробелы, `/`, `@` и другие URL-разделители отклоняются. При формировании
proxy-URL CLI сам заключает IPv6-литерал в квадратные скобки.
- [x] Для типа `external` команды `status`, `env` и `run -- COMMAND ARG...`
проверяют только настроенный адрес и порт; недоступный прокси запрещает запуск
дочерней команды с понятным сообщением.
- [x] До реализации управляемого процесса конфиг с `type=xray` обрабатывается
безопасным отказом: `status`, `env`, `run` и `start` не считают один открытый
порт доказательством управляемого Xray и не запускают дочернюю команду.
- [x] `env` при успехе печатает только восемь разрешённых proxy-переменных в двух
регистрах, а при ошибке не печатает частичный результат; базовый `NO_PROXY`
включает loopback и узел прокси, затем дополняется машинной настройкой.
- [x] Документированный `PROXY_PREFLIGHT_TIMEOUT` сохраняется как положительное
число секунд с прежним значением по умолчанию `2` для проверок доступности из
`status`, `env` и `run`; ошибочное значение даёт понятный отказ.
- [x] `run` без искажений сохраняет аргументы и код возврата команды, передаёт
proxy-переменные только дочернему процессу и возвращает отдельную понятную
ошибку, если команда отсутствует в `PATH`.
- [x] Переносимый Bash-модуль становится единственным владельцем `proxy_on`,
`proxy_off`, `pclaude`, `pcodex`, `myip` и внутренних proxy-помощников;
WSL-функция обновления `hproxy.local` остаётся независимой.
- [x] Обычный запуск dotfiles подключает общий Bash-файл и переносимый
proxy-модуль независимо: после установки доступны все пять proxy-команд и
`update_hproxy`; подключение одного переносимого модуля без остальных
dotfiles также предоставляет все пять proxy-команд.
- [x] Bash-модуль принимает вывод `env` атомарно, проверяет белый список и
наличие каждого из восьми ключей ровно один раз, не использует `eval` и при
любом неверном выводе оставляет прежние восемь переменных без изменений;
`proxy_on` и `proxy_off` больше не делают автоматический запрос внешнего IP.
- [x] Автоматические тесты подтверждают прежний внешний Hiddify-сценарий,
fail-fast, оба регистра переменных, настраиваемый `NO_PROXY`, отсутствие
протечки окружения, обычное и самостоятельное подключение Bash-модуля,
безопасный отказ для `type=xray` и атомарный разбор полного вывода `env`.
@@ -0,0 +1,72 @@
# 02 — Управляемый Xray: полный штатный путь
**What to build:** Пользователь с уже установленным поддерживаемым Xray кладёт
одну VLESS-ссылку в закрытый файл и получает полный штатный сценарий локального
прокси: проверку профиля, запуск на переднем плане, диагностику, запуск команд
через прокси и штатную остановку.
**Blocked by:** 01 — Внешний прокси и переносимый Bash-интерфейс.
**Status:** done
- [x] `start` разрешён только при явном типе `xray`, требует loopback-адрес и
согласованный локальный порт, никогда не скачивает бинарник и выбирает первый
`xray` из `PATH`. Один разрешённый путь используется для `version`,
`run -test` и основного процесса и показывается перед запуском.
- [x] Успешный `xray version` разбирается только по первой строке в форме
`Xray VERSION` с концом строки либо ASCII-пробелом после полного токена
версии. Принимаются только `25.12.8` и `26.3.27`; ведущий мусор, похожий
префикс вроде `26.3.270`, пустой, неверный или завершившийся ошибкой вывод
запрещает преобразование и запуск. Запрошенная или указанная в профиле версия
не подменяет фактическую версию выбранного бинарника.
- [x] Профиль берётся из предусмотренного файла по умолчанию либо из явно
переданного `-f`, содержит ровно одну VLESS-ссылку, а конечный файл после
разрешения симлинка недоступен группе и остальным пользователям.
- [x] Строгий преобразователь поддерживает TCP/RAW с REALITY и XHTTP с TLS,
сохраняет все предусмотренные спецификацией поля, отклоняет повторы,
любой неизвестный query-параметр, отсутствующие обязательные поля и
неподдерживаемые сочетания до запуска Xray.
- [x] Для Xray 26.3.27 корректно обрабатываются `pcs`/`vcn`, а устаревшие
`allowInsecure`/`insecure` не переносятся и дают предупреждение; для явно
выбранного Xray 25.12.8 сохраняется совместимое insecure-поведение, а новые
поля отклоняются без автоматического отката версии.
- [x] `allowInsecure` и `insecure` принимают только точные значения `true` или
`false`; при одновременном присутствии значения должны совпадать. Для
25.12.8 только согласованное истинное значение включает `allowInsecure` с
предупреждением, а для 26.3.27 поле никогда не попадает в JSON и истинное
значение предупреждает об обычной проверке сертификата.
- [x] Созданный клиентский JSON слушает только loopback, предоставляет HTTP и
SOCKS на одном порту, отключает журнал доступа и проходит `xray run -test`
до запуска основного процесса.
- [x] `start` остаётся на переднем плане как супервизор одного дочернего Xray;
после запуска процесс жив и порт принимает соединения, а сбой отдельной
проверки внешнего IP остаётся предупреждением.
- [x] Штатный `start` создаёт закрытое состояние с PID супервизора и дочернего
Xray, разрешённым путём к бинарнику, портом и безопасным отпечатком профиля.
`status`, `env`, `run` и `stop` требуют это состояние, живой супервизор,
ожидаемый дочерний процесс и настроенный порт; один чужой listener или PID
никогда не считается управляемым Xray и не затрагивается.
- [x] `status`, `env`, `run` и `stop` обеспечивают штатный путь управляемого
Xray; `run` никогда не запускает его скрыто, а `stop` может завершить
управляемый процесс после переключения машинного типа на `external`.
Успешный `stop` посылает сигнал супервизору, ограниченно ждёт завершения
супервизора и Xray, очищает штатное состояние и возвращает исходный
foreground-вызов `start` в терминал.
- [x] Диагностика внешнего IP действительно делает запрос через настроенный
локальный HTTP-прокси. Её сбой явно показан как предупреждение и не отменяет
уже успешные проверки процесса и порта.
- [x] Безопасная сводка показывает только полезные несекретные свойства
профиля; XHTTP `path` показывается только как скрытое значение с длиной, а
UUID, ключи, short ID и другие секреты — без фрагментов исходного значения.
Полная VLESS-ссылка и уникальные секреты отсутствуют в stdout, stderr и argv
дочерних процессов при успехе, любом отказе разбора и сбое `run -test`.
- [x] Модульные и интеграционные тесты покрывают обе семьи ссылок, варианты
ALPN, типичную ссылку Hiddify XHTTP/TLS, обе поддержанные версии Xray и
понятные отказы на неподдерживаемом вводе.
- [x] Чёрный ящик для каждой поддержанной версии доказывает полный порядок:
профиль по умолчанию и `-f`, `version`, успешный `run -test` до основного
запуска, живое управляемое состояние и порт, `status`, `env`, дочернюю
команду через `run`, проксированную IP-диагностику, `stop` и ограниченное
завершение исходного `start`. Через один настроенный локальный порт реально
завершаются отдельный HTTP-proxy-запрос и отдельный SOCKS-proxy-запрос.
Подмена порта или состояния чужим процессом даёт безопасный отказ.
@@ -0,0 +1,32 @@
# 03 — Безопасный жизненный цикл управляемого процесса
**What to build:** Управляемый Xray предсказуемо переживает повторные команды,
сигналы, устаревшее состояние и аварийное исчезновение супервизора, не затрагивая
чужие процессы и не оставляя секретные или временные данные.
**Blocked by:** 02 — Управляемый Xray: полный штатный путь.
**Status:** ready-for-agent
- [ ] Супервизор владеет пользовательской блокировкой, допускает только один
управляемый экземпляр и не заменяет запущенный профиль другим без явных
`stop` и нового `start`.
- [ ] Состояние с правами `0600` хранит PID и время старта супервизора и Xray,
путь к бинарнику, порт и безопасный отпечаток профиля, но не ссылку или её
секреты; служебные каталоги имеют права `0700`.
- [ ] `status` и `stop` сверяют PID со временем старта и ожидаемым исполняемым
файлом, поэтому чужой, устаревший и повторно использованный PID никогда не
считается управляемым процессом.
- [ ] SIGINT, SIGTERM и SIGHUP передаются дочернему Xray; штатное завершение
удаляет состояние, сгенерированный JSON и временные файлы.
- [ ] Если супервизор аварийно исчез, `stop` завершает оставшийся Xray только
после полной проверки его личности и затем безопасно очищает состояние.
- [ ] Повторный запуск, смена профиля, конфликт локального порта и несогласованный
машинный адрес завершаются понятным отказом без повреждения уже работающего
экземпляра.
- [ ] Полная VLESS-ссылка и её секреты отсутствуют в stdout, stderr, служебных
файлах и аргументах дочерних процессов; временная конфигурация существует
только внутри закрытого служебного каталога.
- [ ] Автоматические тесты покрывают блокировку, повторный запуск, сигналы,
остановку, очистку, смену профиля, конфликт порта, чужой, устаревший и
повторно использованный PID, а также исчезнувший супервизор.
@@ -0,0 +1,28 @@
# 04 — Проверяемая пользовательская установка Xray
**What to build:** Пользователь без root устанавливает официальный Xray одной
явной командой и получает проверенный бинарник нужной версии без скрытой замены
существующей установки или записи за пределами пользовательских каталогов.
**Blocked by:** 01 — Внешний прокси и переносимый Bash-интерфейс.
**Status:** ready-for-agent
- [ ] `install` поддерживает Linux x86_64 и aarch64, по умолчанию выбирает
зафиксированную версию 26.3.27 и принимает другую только через явный параметр
версии, не объявляя произвольный релиз поддержанным.
- [ ] Архив и файл контрольной суммы скачиваются из официального релиза, после
чего SHA-256 проверяется до распаковки и установки; несовпадение прекращает
операцию без изменения установленного бинарника.
- [ ] Работа ведётся во временном пользовательском каталоге, извлечённый бинарник
сначала проходит проверку `version`, а затем атомарно устанавливается в
пользовательский каталог исполняемых файлов.
- [ ] Существующий бинарник другой версии не перезаписывается без `--replace`;
ошибка, отказ или прерывание не оставляют частично установленный файл.
- [ ] После установки `command -v xray` обязан указывать на установленный файл;
иначе команда завершается с понятной подсказкой исправить `PATH`.
- [ ] Установщик не использует sudo, systemd или системные каталоги, а команды
`start` и запуск без подкоманды никогда не начинают скачивание.
- [ ] Автоматические тесты проверяют обе архитектуры, корректную и повреждённую
контрольную сумму, существующую версию, `--replace`, атомарность и отсутствие
записи вне изолированного пользовательского каталога.
@@ -0,0 +1,37 @@
# 05 — Доставка пакета и итоговая приёмка
**What to build:** Завершённая прокси-подсистема устанавливается отдельно или
вместе с dotfiles, снабжена безопасными шаблонами и понятной инструкцией, а её
заявленные сценарии подтверждены автоматическими и доступными системными
проверками.
**Blocked by:** 03 — Безопасный жизненный цикл управляемого процесса; 04 —
Проверяемая пользовательская установка Xray.
**Status:** ready-for-agent
- [ ] Самостоятельный stow-совместимый пакет доставляет CLI, Bash-модуль и
вымышленные шаблоны машинного INI и VLESS-профиля; реальные пользовательские
настройки, UUID, ссылки и ключи не входят в репозиторий.
- [ ] Пакет можно подключить отдельно через stow, эквивалентные ручные симлинки
или копирование; общий установщик использует тот же пакет без отдельной
реализации и сохраняет повторяемость установки.
- [ ] Основная инструкция объясняет внешний Hiddify и управляемый Xray,
подготовку двух файлов настройки, все команды управления, `proxy_on/off`,
`pclaude/pcodex`, `NO_PROXY`, ограничения CIDR и диагностику.
- [ ] Инструкция фиксирует две поддержанные семьи VLESS, современную проверку
сертификата, явный запасной Xray 25.12.8 и отсутствие systemd, автозапуска и
автоматического перехода между типами прокси.
- [ ] Корневой обзор кратко описывает подсистему, показывает подключение пакета
и направляет к полной инструкции, не дублируя её.
- [ ] Полный автоматический прогон подтверждает Python 3.8-совместимый синтаксис,
внешний Hiddify-сценарий, обе семьи VLESS, обе версии Xray, установку,
жизненный цикл, Bash-интеграцию и отсутствие утечки секретов.
- [ ] На доступной Ubuntu с Python 3.10 проверены установка, `xray run -test` и
работа HTTP и SOCKS через один локальный вход для заявленных совместимых
сочетаний Xray 26.3.27 и 25.12.8.
- [ ] На целевой удалённой системе выполнен тот же пользовательский сценарий;
версия Python в РЕД ОС проверена до заявления поддержки, а недоступная среда
явно записана как неподтверждённая вместо неподкреплённого обещания.
- [ ] После приёмки статус исходной спецификации и документация соответствуют
фактически подтверждённому объёму, включая все оставшиеся ограничения.
+16 -7
View File
@@ -47,7 +47,8 @@ Chezmoi использует отдельный source dir `chezmoi/` внутр
| Пакет | Что делает |
|-------|------------|
| `bash` | Кастомный prompt, функции (`proxy_on/off`, `myip`), подгрузка секретов |
| `bash` | Кастомный prompt, общие функции, подгрузка секретов |
| `xray-proxy` | Переносимый CLI и Bash-команды для явного запуска через прокси |
| `tmux` | Два префикса (`Ctrl+b`, `Ctrl+Space`), vi-режим, split по `\|` / `-` |
| `vim` | Минимальный .vimrc без плагинов, 2 пробела, leader = `Space` |
| `zed` | Конфигурация Zed редактора |
@@ -94,9 +95,17 @@ export ZAI_API_KEY="..." # https://z.ai
Файл подгружается при старте bash и VS Code Server. Не трекается в git.
### Прокси (`hproxy.local`)
### Прокси (`xray-proxy`)
Имя `hproxy.local` — алиас хоста с прокси. Все инструменты (bash, pwsh, VS Code Server) используют его для `proxy_on/off`, не завязываясь на конкретный IP.
Linux/Bash-команды `proxy_on/off`, `pclaude`, `pcodex` и `myip` находятся в
самостоятельном пакете [`xray-proxy`](xray-proxy/README.md). Он читает строгий
машинный INI, проверяет доступность явно выбранного внешнего прокси и не
запускает команду при ошибке. Без конфига сохраняется внешний Hiddify
`hproxy.local:12334`. Управление процессом Xray будет добавлено отдельно;
`type = xray` пока даёт безопасный отказ.
Имя `hproxy.local` — прежний алиас хоста с внешним прокси. Windows и VS Code
Server продолжают использовать его напрямую.
**Порты:** HTTP/HTTPS — `hproxy.local:12334`, SOCKS5 — `socks5://hproxy.local:12334`.
@@ -135,7 +144,7 @@ echo '127.0.0.1 hproxy.local' | sudo tee -a /etc/hosts
**Windows pwsh** — `proxy_on` / `proxy_off` / `myip` работают аналогично bash-версиям.
**`pclaude` / `pcodex`** — явный запуск Claude/Codex CLI через прокси (bash и pwsh). Перед стартом делают fail-fast проверку доступности `hproxy.local:12334` (таймаут — `PROXY_PREFLIGHT_TIMEOUT`, дефолт 2 c); если прокси недоступен — не запускают и возвращают ненулевой код. Proxy-переменные видны только дочернему процессу и не остаются в сессии. Голые `claude`/`codex` не перекрываются и идут напрямую — для прокси нужен явный `p`-префикс.
**`pclaude` / `pcodex`** — явный запуск Claude/Codex CLI через прокси (bash и pwsh). Bash-версия делегирует проверку и запуск `xray-proxy run`; Windows-версия сохраняет прежний адрес `hproxy.local:12334`. Таймаут — `PROXY_PREFLIGHT_TIMEOUT`, по умолчанию 2 c. Если прокси недоступен, клиент не запускается. Proxy-переменные видны только дочернему процессу и не остаются в сессии. Голые `claude`/`codex` не перекрываются и идут напрямую — для прокси нужен явный `p`-префикс.
**Codex GUI на Windows** — chezmoi создает Start Menu ярлык `Codex Proxy`, который запускает Codex Windows App с proxy-переменными:
@@ -178,15 +187,15 @@ NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru
## 📖 Справочник
### Bash-функции (`~/.bash_functions`)
### Bash-функции (`~/.config/xray-proxy/shell.bash`)
| Функция | Описание |
|---------|----------|
| `proxy_on` | Включает HTTP/HTTPS/SOCKS5 прокси через `hproxy.local:12334` |
| `proxy_on` | Проверяет настроенный прокси и атомарно включает восемь переменных |
| `proxy_off` | Очищает proxy-переменные |
| `pclaude` / `pcodex` | Запуск Claude/Codex CLI через прокси с fail-fast pre-flight |
| `myip` | Показывает текущий внешний IP |
| `update_hproxy` | Обновляет `hproxy.local` в `/etc/hosts` через `setup-hproxy.sh` (WSL NAT) |
| `update_hproxy` | Остаётся в `~/.bash_functions` и обновляет WSL NAT-алиас |
### PowerShell-функции (`~/.config/powershell/profile.ps1`)
+1
View File
@@ -16,6 +16,7 @@ esac
# Файлы из этого репозитория (stow кладет их в $HOME).
[ -f ~/.bash_functions ] && source ~/.bash_functions
[ -f ~/.config/xray-proxy/shell.bash ] && source ~/.config/xray-proxy/shell.bash
[ -f ~/.bash_prompt ] && source ~/.bash_prompt
# Локальные секреты (не хранить в git/stow).
-67
View File
@@ -23,70 +23,3 @@ update_hproxy() {
echo "✅ hproxy.local -> $(getent hosts hproxy.local 2>/dev/null | awk '{print $1}')"
}
# Единый источник правды для прокси-эндпоинта (не экспортируется; идемпотентно
# при повторном source). NO_PROXY: ::1 и [::1] нужны, т.к. localhost в dual-stack
# Linux резолвится в IPv6, и без них локальные MCP/loopback-соединения
# (chroma-mcp, claude-mem worker и т.п.) уходят в SOCKS5 через hproxy.local.
: "${_PROXY_HOST:=hproxy.local}"
: "${_PROXY_PORT:=12334}"
: "${_PROXY_NO_PROXY:=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru}"
# Внутренний: экспортирует proxy-env (+ lower-case дубли для Python-стека).
# Единственное место, где формируются значения переменных.
_proxy_export() {
export HTTP_PROXY="http://${_PROXY_HOST}:${_PROXY_PORT}"
export HTTPS_PROXY="http://${_PROXY_HOST}:${_PROXY_PORT}"
export ALL_PROXY="socks5://${_PROXY_HOST}:${_PROXY_PORT}"
export NO_PROXY="$_PROXY_NO_PROXY"
export http_proxy="$HTTP_PROXY"
export https_proxy="$HTTPS_PROXY"
export all_proxy="$ALL_PROXY"
export no_proxy="$_PROXY_NO_PROXY"
}
# Внутренний: fail-fast проверка доступности прокси (только reachability —
# не доказательство, что трафик реально проксируется). Таймаут покрывает и
# медленный DNS-резолв; при зависании — отказ запуска (безопасный отказ).
_proxy_reachable() {
timeout "${PROXY_PREFLIGHT_TIMEOUT:-2}" \
bash -c "exec 3<>/dev/tcp/${_PROXY_HOST}/${_PROXY_PORT}" 2>/dev/null
}
# Внутренний: запуск команды с proxy-env. Pre-flight, затем subshell+exec —
# env виден только дочернему процессу, не протекает в родительский shell,
# код возврата пробрасывается.
_proxy_run() {
local cmd="$1"; shift
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "❌ Команда не найдена в PATH: $cmd" >&2
return 127
fi
if ! _proxy_reachable; then
echo "❌ Прокси ${_PROXY_HOST}:${_PROXY_PORT} недоступен — запуск '$cmd' отменён." >&2
return 1
fi
# exec и так резолвит через PATH в обход функций/алиасов.
( _proxy_export; exec "$cmd" "$@" )
}
pclaude() { _proxy_run claude "$@"; }
pcodex() { _proxy_run codex "$@"; }
# Удобные функции-переключатели для терминала.
proxy_on() {
_proxy_export
echo "✅ Proxy enabled. Checking route..."
# Подставляем результат выполнения curl прямо в строку
echo "🌍 Current IP: $(curl -s --max-time 3 https://ifconfig.me)"
}
proxy_off() {
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
unset http_proxy https_proxy all_proxy no_proxy
echo "❌ Proxy disabled. Checking route..."
echo "🏠 Current IP: $(curl -s --max-time 3 https://ifconfig.me)"
}
# Заодно можно добавить алиас, чтобы быстро чекать IP в любой момент без переключения
alias myip='echo "IP: $(curl -s --max-time 3 https://ifconfig.me)"'
@@ -242,10 +242,12 @@ Xray. Параметры query не зависят от порядка, но н
[стандарт VLESS-ссылок](https://github.com/XTLS/Xray-core/discussions/716).
Перед запуском CLI печатает компактную безопасную сводку: сервер, порт,
транспорт, защиту, SNI, ALPN, Host, путь, fingerprint и локальный порт. Обычный
режим не выводит подробный журнал Xray: `-v` добавляет информационные сообщения,
а `--debug` включает подробную диагностику. UUID, REALITY password/public key,
short ID и другие чувствительные значения выводятся только сокращённо.
транспорт, защиту, SNI, ALPN, Host, наличие и длину пути, fingerprint и локальный
порт. XHTTP `path` может содержать tenant ID или bearer-подобный токен, поэтому
его содержимое никогда не печатается. Обычный режим не выводит подробный журнал
Xray: `-v` добавляет информационные сообщения, а `--debug` включает подробную
диагностику. UUID, REALITY password/public key, short ID и другие чувствительные
значения обозначаются без фрагментов исходного значения.
В создаваемом JSON журнал доступа отключён, а уровень ошибок по умолчанию —
`warning`.
@@ -254,16 +256,21 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure=
[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).
Поэтому преобразователь принимает эти поля, но для Xray 26.3.27 не переносит
их в JSON и предупреждает, что сертификат будет проверен обычным способом.
Преобразователь принимает для обоих полей только точные строчные значения
`true` и `false`. Если присутствуют оба поля, их значения должны совпадать;
конфликт и любое другое написание отклоняются до создания JSON. Для Xray
26.3.27 поля не переносятся в JSON; истинное значение даёт предупреждение, что
сертификат будет проверен обычным способом, а ложное не требует предупреждения.
Соединение после этого может успешно заработать или завершиться ошибкой
сертификата; отдельного раннего отказа нет.
При явно выбранном Xray 25.12.8 преобразователь сохраняет
`allowInsecure=true` и предупреждает одновременно об отключённой проверке
сертификата и об использовании старой совместимой версии. Автоматический откат
с 26.x на 25.12.8 запрещён. Новые `pcs`/`vcn` в режиме 25.12.8 отклоняются как
несовместимые вместо попытки угадать их старое представление.
При фактически выбранном Xray 25.12.8 преобразователь сохраняет
`allowInsecure=true` только для согласованного истинного legacy-значения и
предупреждает одновременно об отключённой проверке сертификата и об
использовании старой совместимой версии. Ложное или отсутствующее значение не
включает поле. Автоматический откат с 26.x на 25.12.8 запрещён. Новые
`pcs`/`vcn` в режиме 25.12.8 отклоняются как несовместимые вместо попытки
угадать их старое представление.
### Установка Xray
@@ -284,8 +291,13 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure=
указывает именно на установленный файл; иначе завершается с подсказкой исправить
`PATH`. `start` всегда показывает фактический путь и версию выбранного бинарника.
Перед созданием JSON CLI читает фактическую версию из `xray version` и выбирает
только совместимое с ней представление TLS-полей. Версия никогда не выводится
Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и
запускает этим файлом `xray version`. Первая строка должна начинаться с точного
токена `Xray VERSION`, после полного токена версии допускается только конец
строки либо ASCII-пробел. Принимаются только версии 25.12.8 и 26.3.27; ведущий
мусор, похожий более длинный токен, пустой, неверный, завершившийся ошибкой или
содержащий другую версию вывод запрещает запуск. Тот же разрешённый файл
используется для `run -test` и основного процесса. Версия никогда не выводится
из содержимого VLESS-ссылки.
Xray 25.12.8 — документированный запасной вариант только для старых Hiddify-
@@ -331,7 +343,9 @@ CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `r
Сгенерированный JSON и временные файлы создаются внутри служебного каталога и
удаляются супервизором после завершения Xray. Каталоги создаются с правами
`0700`, файлы с конфигурацией и состоянием — `0600`. Исходная VLESS-ссылка не
попадает в обычный вывод и никогда не передаётся аргументом командной строки.
попадает в stdout или stderr при успехе, ошибках разбора и сбое `run -test` и
никогда не передаётся аргументом командной строки. Те же поверхности не содержат
UUID, ключи, short ID и другие уникальные секретные маркеры профиля.
`run` никогда не запускает Xray. Для типа `xray` он проверяет PID управляемого
процесса и локальный порт; для типа `external` — только явно настроенный адрес и
@@ -407,8 +421,8 @@ README репозитория только кратко описывает по
все показанные варианты ALPN. Отдельный регрессионный пример повторяет форму
типичной ссылки Hiddify XHTTP/TLS с `hiddify`, `core`, `extra`,
`headerType=none`, `allowInsecure` и `insecure`.
- Неподдерживаемые транспорт, защита, отсутствующие обязательные поля и
неизвестные критичные параметры дают понятный отказ до запуска Xray.
- Неподдерживаемые транспорт, защита, отсутствующие обязательные поля и любой
неизвестный query-параметр дают понятный отказ до запуска Xray.
- Сгенерированный JSON проходит `xray run -test` отдельно на Xray 26.3.27 и
25.12.8 для заявленных совместимыми сочетаний. Интеграционная проверка каждой
версии подтверждает, что один локальный вход принимает HTTP и SOCKS.
+1 -1
View File
@@ -231,7 +231,7 @@ if ! command -v "$STOW_BIN" &>/dev/null; then
exit 1
fi
PACKAGES=(bash tmux vim zed vscode agents codex gemini)
PACKAGES=(bash tmux vim zed vscode agents codex gemini xray-proxy)
AVAILABLE_PACKAGES=()
info "Применяю stow пакеты..."
+4
View File
@@ -38,6 +38,7 @@ setup_fixture() {
"$REPO_ROOT/codex" \
"$REPO_ROOT/gemini" \
"$REPO_ROOT/claude" \
"$REPO_ROOT/xray-proxy" \
"$REPO_ROOT/scripts" \
"$repo_root/"
@@ -96,6 +97,9 @@ test_fresh_install_uses_home_target() {
run_install "$repo_root" "$home_dir" env WSL_DISTRO_NAME= INSTALL_INTERACTIVE_MODE=never >/tmp/test-install.out
[ -L "$home_dir/.bash_custom" ] || fail "Expected .bash_custom symlink in HOME"
[ -x "$home_dir/.local/bin/xray-proxy" ] || fail "Expected executable xray-proxy CLI in HOME"
[ -f "$home_dir/.config/xray-proxy/shell.bash" ] || fail "Expected xray-proxy shell module"
[ ! -e "$home_dir/README.md" ] || fail "Package README must not be stowed into HOME"
[ -L "$home_dir/.claude/skills" ] || fail "Expected ~/.claude/skills symlink"
assert_file_contains "$home_dir/.bashrc" "[ -f ~/.bash_custom ] && source ~/.bash_custom"
assert_not_exists "$fixture/.bash_custom"
+184 -79
View File
@@ -3,6 +3,8 @@ 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/xray-proxy/.config/xray-proxy/shell.bash"
pass_count=0
@@ -17,101 +19,204 @@ ok() {
}
assert_eq() {
local expected="$1" actual="$2" msg="$3"
[ "$expected" = "$actual" ] || fail "$msg (ожидалось '$expected', получено '$actual')"
local expected="$1" actual="$2" message="$3"
[ "$expected" = "$actual" ] || fail "$message (ожидалось '$expected', получено '$actual')"
}
# Изолированный fake-CLI на PATH: печатает env и аргументы, отдаёт заданный код.
make_fake_bin() {
local dir
dir="$(mktemp -d /tmp/proxy-launchers-test.XXXXXX)"
cat >"$dir/fakecli" <<'EOF'
#!/usr/bin/env bash
echo "HTTP_PROXY=${HTTP_PROXY:-}"
echo "NO_PROXY=${NO_PROXY:-}"
for a in "$@"; do echo "ARG=$a"; done
exit "${FAKE_EXIT:-0}"
valid_env() {
cat <<'EOF'
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
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
chmod +x "$dir/fakecli"
echo "$dir"
}
# --- Каждый кейс в отдельном subshell, чтобы env/функции не протекали ---
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"
done
}
# 1. Прокси недоступен → бинарник НЕ вызывается, ненулевой код.
# Самостоятельный модуль предоставляет весь публичный Bash-интерфейс.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 1; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
out="$(_proxy_run fakecli 2>&1)"; rc=$?
[ "$rc" -ne 0 ] || fail "недоступный прокси: ожидался ненулевой код"
case "$out" in *ARG=*) fail "недоступный прокси: бинарник не должен запускаться";; esac
case "$out" in *"недоступен"*) ;; *) fail "недоступный прокси: нет понятного сообщения";; esac
) && ok "недоступный прокси → fail-fast без запуска бинарника"
source "$SHELL_MODULE"
assert_five_proxy_commands
) && ok "самостоятельное подключение предоставляет пять proxy-команд"
# 2. proxy-env виден дочернему процессу и НЕ протекает в родительский shell.
# Обычная .bash_custom независимо подключает общие функции и proxy-модуль.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
unset HTTP_PROXY NO_PROXY || true
out="$(_proxy_run fakecli)"
case "$out" in *"HTTP_PROXY=http://hproxy.local:12334"*) ;; *) fail "child: HTTP_PROXY не выставлен";; esac
case "$out" in *"NO_PROXY=hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru"*) ;; *) fail "child: NO_PROXY неверен";; esac
[ -z "${HTTP_PROXY:-}" ] || fail "leak: HTTP_PROXY протёк в родительский shell"
[ -z "${NO_PROXY:-}" ] || fail "leak: NO_PROXY протёк в родительский shell"
) && ok "proxy-env в дочернем процессе, без протечки в shell"
fixture="$(mktemp -d /tmp/proxy-bash-custom-test.XXXXXX)"
mkdir -p "$fixture/.config/xray-proxy"
ln -s "$CUSTOM_FILE" "$fixture/.bash_custom"
ln -s "$FUNCTIONS_FILE" "$fixture/.bash_functions"
ln -s "$SHELL_MODULE" "$fixture/.config/xray-proxy/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"
# 3. Проброс кода возврата дочернего процесса.
# proxy_on атомарно применяет ровно восемь переменных и не запрашивает IP.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
FAKE_EXIT=42 _proxy_run fakecli >/dev/null; rc=$?
assert_eq 42 "$rc" "код возврата дочернего процесса не проброшен"
) && ok "проброс кода возврата дочернего процесса"
# 4. Аргументы с пробелами и `--` доходят без искажений.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
bindir="$(make_fake_bin)"; export PATH="$bindir:$PATH"
out="$(_proxy_run fakecli "a b" -- --flag)"
case "$out" in *$'ARG=a b\n'*) ;; *) fail "arg с пробелом потерян";; esac
case "$out" in *"ARG=--"*) ;; *) fail "разделитель -- потерян";; esac
case "$out" in *"ARG=--flag"*) ;; *) fail "флаг после -- потерян";; esac
) && ok "аргументы с пробелами и -- проброшены без искажений"
# 5. Команда не в PATH → отдельное сообщение, код 127.
(
source "$FUNCTIONS_FILE"
_proxy_reachable() { return 0; }
out="$(_proxy_run definitely-no-such-cli-xyz 2>&1)"; rc=$?
assert_eq 127 "$rc" "отсутствующая команда: ожидался код 127"
case "$out" in *"не найдена"*) ;; *) fail "отсутствующая команда: нет понятного сообщения";; esac
) && ok "команда не в PATH → код 127 с сообщением"
# 6. Регресс proxy_on: точный контракт экспортируемых переменных.
(
source "$FUNCTIONS_FILE"
curl() { echo "1.2.3.4"; } # без сети
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
np="hproxy.local,localhost,127.0.0.1,::1,[::1],.concept-software.ru"
assert_eq "http://hproxy.local:12334" "${HTTP_PROXY}" "proxy_on HTTP_PROXY"
assert_eq "http://hproxy.local:12334" "${HTTPS_PROXY}" "proxy_on HTTPS_PROXY"
assert_eq "socks5://hproxy.local:12334" "${ALL_PROXY}" "proxy_on ALL_PROXY"
assert_eq "$np" "${NO_PROXY}" "proxy_on NO_PROXY"
assert_eq "${HTTP_PROXY}" "${http_proxy}" "proxy_on http_proxy дубль"
assert_eq "${HTTPS_PROXY}" "${https_proxy}" "proxy_on https_proxy дубль"
assert_eq "${ALL_PROXY}" "${all_proxy}" "proxy_on all_proxy дубль"
assert_eq "$np" "${no_proxy}" "proxy_on no_proxy дубль"
) && ok "регресс proxy_on: контракт переменных не изменился"
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"
# Любой неверный машинный вывод оставляет прежнее окружение целиком.
missing_output="$(valid_env | sed '/^no_proxy=/d')"
unknown_output="$(valid_env; printf 'EXTRA_PROXY=bad\n')"
duplicate_output="$(valid_env; printf 'HTTP_PROXY=duplicate\n')"
for bad_output in \
"$missing_output" \
"$unknown_output" \
"$duplicate_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
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 "атомарность нарушена для неверного вывода"
done
ok "неверный env-вывод отклоняется без частичного изменения окружения"
# Даже полный stdout не применяется, если CLI завершился с ошибкой.
(
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"
done
) && ok "stdout неуспешного env не изменяет окружение"
# CRLF и NUL считаются повреждённым машинным выводом и не могут быть молча
# нормализованы перед атомарной проверкой.
for malformed_kind in crlf nul; do
(
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() {
case "$malformed_kind" in
crlf)
while IFS= read -r line; do
printf '%s\r\n' "$line"
done < <(valid_env)
;;
nul)
printf 'HTTP_PROXY=http://proxy.test:12\00034\n'
valid_env | sed '/^HTTP_PROXY=/d'
;;
esac
}
if proxy_on >/dev/null 2>&1; then
fail "proxy_on принял $malformed_kind в env-выводе"
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 изменился после $malformed_kind"
done
) || fail "атомарность нарушена для $malformed_kind"
done
ok "CRLF и NUL отклоняются без изменения окружения"
# Обёртки передают CLI команду, аргументы и код возврата без искажений.
(
source "$SHELL_MODULE"
_proxy_cli() {
printf '<%s>\n' "$@"
return 42
}
set +e
output="$(pclaude "a b" -- --flag)"
rc=$?
set -e
assert_eq 42 "$rc" "pclaude не сохранил код возврата"
assert_eq $'<run>\n<-->\n<claude>\n<a b>\n<-->\n<--flag>' "$output" "pclaude исказил аргументы"
set +e
output="$(pcodex --search)"
rc=$?
set -e
assert_eq 42 "$rc" "pcodex не сохранил код возврата"
assert_eq $'<run>\n<-->\n<codex>\n<--search>' "$output" "pcodex исказил аргументы"
) && ok "pclaude и pcodex делегируют run и сохраняют аргументы/код"
# proxy_off очищает оба регистра и не смешивает действие с диагностикой.
(
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 сохранён"
# 7. shellcheck (если доступен).
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -s bash "$FUNCTIONS_FILE" || fail "shellcheck нашёл проблемы в bash/.bash_functions"
shellcheck -s bash "$FUNCTIONS_FILE" "$CUSTOM_FILE" "$SHELL_MODULE" "$0" \
|| fail "shellcheck нашёл проблемы"
ok "shellcheck чист"
else
echo "skip - shellcheck не установлен"
+467
View File
@@ -0,0 +1,467 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy"
def checking_socket_setup(outcome="success", close_delay=0.0):
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, time\n"
"expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n"
"class Connection:\n"
" def close(self):\n"
" time.sleep("
+ repr(close_delay)
+ ")\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 XrayProxyCliTest(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"
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"
config_dir.mkdir(exist_ok=True)
(config_dir / "config").write_text(
"[proxy]\n"
"type = {}\n"
"name = {}\n"
"address = {}\n"
"port = {}\n"
"no_proxy_extra = {}\n".format(
proxy_type, name, address, port, no_proxy_extra
),
encoding="utf-8",
)
def write_raw_config(self, content):
config_dir = self.config_home / "proxy"
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)
return subprocess.run(
[str(CLI)] + list(arguments),
text=True,
capture_output=True,
env=command_environment,
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):
result = self.run_cli()
self.assertEqual(2, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("укажите явную подкоманду", result.stderr)
self.assertFalse((self.config_home / "proxy").exists())
def test_old_python_version_gets_clear_error_before_main_program(self):
code = (
"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,
)
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_broken_config_symlink_is_not_treated_as_missing_config(self):
config_dir = self.config_home / "proxy"
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)
self.assertEqual(
[
"HTTP_PROXY=" + proxy_url,
"HTTPS_PROXY=" + proxy_url,
"ALL_PROXY=" + socks_url,
"NO_PROXY=" + no_proxy,
"http_proxy=" + proxy_url,
"https_proxy=" + proxy_url,
"all_proxy=" + socks_url,
"no_proxy=" + no_proxy,
],
result.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),
)
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_preflight_timeout_bounds_delayed_dns_resolution(self):
self.write_config(12334, address="slow-dns.example")
setup = (
"import socket, time; "
"socket.getaddrinfo = lambda *args, **kwargs: time.sleep(5)"
)
started = time.monotonic()
result = self.run_cli_with_socket_setup(
("status",), setup, {"PROXY_PREFLIGHT_TIMEOUT": "0.1"}
)
elapsed = time.monotonic() - started
self.assertEqual(1, result.returncode)
self.assertLess(elapsed, 1.0)
self.assertIn("таймаут", result.stderr)
self.assertNotIn("Traceback", result.stderr)
def test_preflight_timer_is_cancelled_before_closing_successful_connection(self):
self.write_config(23456)
setup = checking_socket_setup(close_delay=0.1)
result = self.run_cli_with_socket_setup(
("status",),
setup,
{"PROXY_PREFLIGHT_TIMEOUT": "0.01"},
expected_connection=("127.0.0.1", 23456, 0.01),
)
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("доступен", result.stdout)
self.assertNotIn("Traceback", 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)
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:
with self.subTest(command=command):
result = self.run_cli(*command)
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("Ошибка:", result.stderr)
self.assertFalse(marker.exists())
def test_external_start_fails_without_starting_anything(self):
self.write_config(12334)
result = self.run_cli("start")
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
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),
)
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_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)
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)
def test_invalid_preflight_timeout_is_rejected_by_status_env_and_run(self):
self.write_config(12334)
for value in ("", "zero", "0", "-1", "nan", "inf"):
for command in (("status",), ("env",), ("run", "--", "true")):
with self.subTest(value=value, command=command):
result = self.run_cli(
*command, environment={"PROXY_PREFLIGHT_TIMEOUT": value}
)
self.assertNotEqual(0, result.returncode)
self.assertIn("положительным числом", result.stderr)
if command == ("env",):
self.assertEqual("", result.stdout)
def test_extreme_positive_timeouts_fail_without_traceback(self):
self.write_config(12334)
for value in ("1e-7", "1e308"):
with self.subTest(value=value):
result = self.run_cli(
"status", environment={"PROXY_PREFLIGHT_TIMEOUT": value}
)
self.assertNotEqual(0, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("Ошибка:", result.stderr)
self.assertNotIn("Traceback", 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"
)
invalid_configs = {
"unknown section": valid + "[other]\nkey = value\n",
"default section": "[DEFAULT]\nfoo = bar\n" + valid,
"unknown key": valid + "extra = value\n",
"wrong key case": valid.replace("type = external", "Type = external"),
"duplicate key": valid + "port = 12335\n",
"missing key": valid.replace("name = Test\n", ""),
"multiline value": valid.replace("name = Test", "name = Test\n continued"),
"nul value": valid.replace("name = Test", "name = Te\0st").encode("utf-8"),
"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(
"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)
def test_address_accepts_only_ip_literals_or_ascii_dns_names(self):
valid_addresses = ("127.0.0.1", "2001:db8::1", "proxy-1.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)
invalid_addresses = (
"http://proxy.example",
"proxy.example:12334",
"[::1]",
"fe80::1%eth0",
"has space",
"proxy/path",
"user@proxy",
".leading.example",
"trailing.example.",
"bad..example",
"-bad.example",
"bad-.example",
"прокси.example",
)
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)
if __name__ == "__main__":
unittest.main()
+890
View File
@@ -0,0 +1,890 @@
#!/usr/bin/env python3
import json
import os
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from urllib.parse import quote
REPO_ROOT = Path(__file__).resolve().parents[1]
CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy"
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
FAKE_XRAY = r'''#!/usr/bin/env python3
import json
import os
import re
import signal
import socket
import sys
import threading
log_path = os.environ["FAKE_XRAY_LOG"]
def record(kind, **values):
values["kind"] = kind
values["argv"] = sys.argv[1:]
values["executable"] = os.path.realpath(sys.argv[0])
with open(log_path, "a", encoding="utf-8") as stream:
stream.write(json.dumps(values, sort_keys=True) + "\n")
if sys.argv[1:] == ["version"]:
record("version")
print(os.environ["FAKE_XRAY_VERSION"])
raise SystemExit(int(os.environ.get("FAKE_VERSION_EXIT", "0")))
is_test = len(sys.argv) == 5 and sys.argv[1:4] == ["run", "-test", "-c"]
is_run = len(sys.argv) == 4 and sys.argv[1:3] == ["run", "-c"]
if not (is_test or is_run):
record("bad-argv")
raise SystemExit(64)
config_path = sys.argv[-1]
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 sys.argv[2] == "-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]
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listen_port = int(os.environ.get("FAKE_XRAY_LISTEN_PORT", inbound["port"]))
server.bind((inbound["listen"], listen_port))
server.listen(16)
server.settimeout(0.1)
stopping = False
def stop(signum, _frame):
global stopping
record("signal", signum=signum)
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"
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
)
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()
'''
class ManagedXrayCliTest(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"
self.xray.write_text(FAKE_XRAY, encoding="utf-8")
self.xray.chmod(0o755)
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",
"XRAY_PROXY_STOP_TIMEOUT": "2",
}
)
self.port = self.free_port()
self.write_machine_config("xray")
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"
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),
encoding="utf-8",
)
def write_profile(self, url, explicit=False):
if explicit:
path = self.root / "chosen.url"
else:
path = self.config_home / "xray-proxy/default.url"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(url + "\n", encoding="utf-8")
path.chmod(0o600)
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="", modern=True):
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
)
if modern
else ""
)
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)
return subprocess.run(
[str(CLI)] + list(args),
text=True,
capture_output=True,
env=env,
timeout=timeout,
check=False,
)
def wait_for_state(self, process):
state = self.state_home / "xray-proxy/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 start(self, profile=None, version="26.3.27"):
self.environment["FAKE_XRAY_VERSION"] = "Xray {} (fake)".format(version)
args = [str(CLI), "start"]
if profile is not None:
args.extend(["-f", str(profile)])
process = subprocess.Popen(
args,
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_versions_and_profile_locations(self):
profiles = (("26.3.27", self.xhttp_profile(), False), ("25.12.8", self.tcp_profile(), True))
for version, profile_text, explicit in profiles:
with self.subTest(version=version):
profile = self.write_profile(profile_text, explicit=explicit)
process = self.start(profile if explicit else None, version)
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)
self.assertIn(str(self.xray.resolve()), process.stdout.readline())
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 / "xray-proxy/state.json").exists())
self.assertFalse((self.state_home / "xray-proxy/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 / "xray-proxy/state.json"
config_path = self.state_home / "xray-proxy/client.json"
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(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(64, len(state["profile_fingerprint"]))
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_old_version_preserves_explicit_legacy_insecure(self):
profile_text = self.xhttp_profile(
"&allowInsecure=true&insecure=true", modern=False
)
self.write_profile(profile_text)
process = self.start(version="25.12.8")
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]
self.assertEqual("xhttp", outbound["streamSettings"]["network"])
self.assertTrue(outbound["streamSettings"]["tlsSettings"]["allowInsecure"])
self.assertIn("Предупреждение", stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self):
invalid_versions = (
"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("@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 = (
("26.3.27", "", False, False),
("26.3.27", "&allowInsecure=false", False, False),
("26.3.27", "&allowInsecure=false&insecure=false", False, False),
("26.3.27", "&insecure=true", True, False),
("26.3.27", "&allowInsecure=true&insecure=true", True, False),
("25.12.8", "", False, False),
("25.12.8", "&insecure=false", False, False),
("25.12.8", "&allowInsecure=false&insecure=false", False, False),
("25.12.8", "&allowInsecure=true", True, True),
("25.12.8", "&allowInsecure=true&insecure=true", True, True),
)
for version, fields, warns, emitted in cases:
with self.subTest(version=version, fields=fields):
profile_text = self.xhttp_profile(fields, modern=version == "26.3.27")
self.write_profile(profile_text)
process = self.start(version=version)
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.assertEqual(emitted, tls.get("allowInsecure", False))
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()
old_with_modern = self.xhttp_profile()
self.write_profile(old_with_modern)
result = self.run_cli(
"start", environment={"FAKE_XRAY_VERSION": "Xray 25.12.8 (fake)"}
)
self.assertNotEqual(0, result.returncode)
self.assertIn("pcs/vcn", result.stderr)
self.assertEqual(["version"], [entry["kind"] for entry in self.log_entries()])
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)],
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")
process = subprocess.Popen(
[str(CLI), "start"],
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)
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 / "xray-proxy/state.json").exists())
self.assertFalse((self.state_home / "xray-proxy/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 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)
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)
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 / "xray-proxy/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/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 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 / "xray-proxy"
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"),
"profile_fingerprint": "0" * 64,
}
(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)
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/config"
config_path.write_text(
config_path.read_text(encoding="utf-8").replace(
"port = {}".format(managed_port), "port = {}".format(foreign_port)
),
encoding="utf-8",
)
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())
stopped = self.run_cli("stop")
stdout, stderr = process.communicate(timeout=4)
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()
def test_verified_child_must_own_configured_listener(self):
profile_text = self.tcp_profile()
self.write_profile(profile_text)
wrong_port = self.free_port()
listener = socket.socket()
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", self.port))
listener.listen(16)
listener.settimeout(0.05)
serving = threading.Event()
serving.set()
def serve_foreign():
while serving.is_set():
try:
client, _address = listener.accept()
except socket.timeout:
continue
try:
client.settimeout(0.2)
request = client.recv(8192)
if b"ifconfig.me" in request:
body = b"203.0.113.88\n"
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
)
except OSError:
pass
finally:
client.close()
thread = threading.Thread(target=serve_foreign, daemon=True)
thread.start()
process = subprocess.Popen(
[str(CLI), "start"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=dict(self.environment, FAKE_XRAY_LISTEN_PORT=str(wrong_port)),
)
state_path = self.state_home / "xray-proxy/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)
marker = self.root / "foreign-listener-child-ran"
results = [
self.run_cli("status"),
self.run_cli("env"),
self.run_cli("run", "--", "touch", str(marker)),
self.run_cli("stop"),
]
stdout, stderr = process.communicate(timeout=4)
serving.clear()
thread.join(timeout=1)
self.assertTrue(all(result.returncode != 0 for result in results))
self.assertTrue(all("listener" in result.stderr for result in results))
self.assertFalse(marker.exists())
self.assertNotEqual(0, process.returncode)
self.assertIn("listener", stderr)
self.assert_no_secrets(stdout + stderr, profile_text)
with socket.create_connection(("127.0.0.1", self.port), timeout=1):
pass
listener.close()
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 / "xray-proxy/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_absolute_relative_and_path_symlink_cli_invocations_are_manageable(self):
proxy_bin = self.root / "proxy-bin"
proxy_bin.mkdir()
(proxy_bin / "xray-proxy").symlink_to(CLI)
invocations = (
([str(CLI)], REPO_ROOT, self.environment),
([os.path.relpath(str(CLI), str(REPO_ROOT))], REPO_ROOT, self.environment),
(
["xray-proxy"],
self.root,
dict(
self.environment,
PATH=str(proxy_bin)
+ os.pathsep
+ str(self.bin_dir)
+ os.pathsep
+ os.environ["PATH"],
),
),
)
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 / "xray-proxy/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")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,6 @@
[proxy]
type = external
name = Hiddify
address = hproxy.local
port = 12334
no_proxy_extra = .example.internal,git.example.internal
@@ -0,0 +1 @@
vless://11111111-2222-4333-8444-555555555555@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#Synthetic-example
+116
View File
@@ -0,0 +1,116 @@
# Переносимый Bash-интерфейс xray-proxy. Файл можно подключать отдельно от
# остальных dotfiles: source ~/.config/xray-proxy/shell.bash
_proxy_cli() {
"${XRAY_PROXY_BIN:-xray-proxy}" "$@"
}
_proxy_apply_env_file() {
local output_file="$1" line key value nul_prefix
local -A proxy_values=()
local -A proxy_seen=()
local -a allowed_keys=(
HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
http_proxy https_proxy all_proxy no_proxy
)
if IFS= read -r -d '' nul_prefix < "$output_file"; then
echo "Ошибка: xray-proxy env вернул запрещённый NUL" >&2
return 1
fi
while IFS= read -r line || [ -n "$line" ]; do
if [[ "$line" == *$'\r'* ]]; then
echo "Ошибка: xray-proxy env вернул запрещённый CR" >&2
return 1
fi
if [[ "$line" != *=* ]]; then
echo "Ошибка: xray-proxy 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) ;;
*)
echo "Ошибка: xray-proxy env вернул неизвестный ключ: $key" >&2
return 1
;;
esac
if [[ -n "${proxy_seen[$key]+set}" ]]; then
echo "Ошибка: xray-proxy env повторил ключ: $key" >&2
return 1
fi
proxy_seen["$key"]=1
proxy_values["$key"]="$value"
done < "$output_file"
for key in "${allowed_keys[@]}"; do
if [[ -z "${proxy_seen[$key]+set}" ]]; then
echo "Ошибка: xray-proxy env не вернул обязательный ключ: $key" >&2
return 1
fi
done
if [ "${#proxy_seen[@]}" -ne 8 ]; then
echo "Ошибка: xray-proxy env должен вернуть ровно восемь ключей" >&2
return 1
fi
for key in "${allowed_keys[@]}"; do
printf -v "$key" '%s' "${proxy_values[$key]}"
done
export HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
export http_proxy https_proxy all_proxy no_proxy
}
proxy_on() {
local output_file old_umask
old_umask="$(umask)"
umask 077
if ! output_file="$(mktemp "${TMPDIR:-/tmp}/xray-proxy-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
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 "Прокси включён"
}
proxy_off() {
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
unset http_proxy https_proxy all_proxy no_proxy
echo "Прокси выключен"
}
_proxy_run() {
local command_name="${1:-}"
if [ -z "$command_name" ]; then
echo "Ошибка: не указана команда" >&2
return 2
fi
shift
_proxy_cli run -- "$command_name" "$@"
}
pclaude() { _proxy_run claude "$@"; }
pcodex() { _proxy_run codex "$@"; }
myip() {
local address
if ! address="$(curl -fsS --max-time 3 https://ifconfig.me)"; then
echo "Ошибка: не удалось определить внешний IP" >&2
return 1
fi
printf 'IP: %s\n' "$address"
}
+1123
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
^/README\.md$
+147
View File
@@ -0,0 +1,147 @@
# xray-proxy
Переносимый Linux/Bash-интерфейс для безопасного запуска команд через явно
настроенный внешний прокси или через управляемый пользовательский Xray.
CLI требует Python 3.8+ и использует только стандартную библиотеку. Он ничего не
устанавливает и не запускает без явной подкоманды. `start` использует первый
`xray` из `PATH`; скачивания и установки при запуске нет.
## Подключение
В составе dotfiles:
```bash
cd ~/dotfiles
stow xray-proxy
source ~/.config/xray-proxy/shell.bash
```
Полный `./install.sh` выполняет тот же `stow` вместе с остальными пакетами, а
`.bash_custom` подключает модуль автоматически.
Без остальных dotfiles можно создать две ссылки:
```bash
mkdir -p ~/.local/bin ~/.config/xray-proxy
ln -s /path/to/xray-proxy/.local/bin/xray-proxy ~/.local/bin/xray-proxy
ln -s /path/to/xray-proxy/.config/xray-proxy/shell.bash \
~/.config/xray-proxy/shell.bash
printf '%s\n' 'source ~/.config/xray-proxy/shell.bash' >> ~/.bashrc
```
Вместо ссылок файлы можно скопировать в те же пути. Исполняемому файлу нужен
режим `chmod 755`. Каталог `~/.local/bin` должен входить в `PATH`.
## Машинный конфиг
Скопируйте пример и отредактируйте его:
```bash
mkdir -p ~/.config/proxy
cp ~/.config/xray-proxy/examples/config ~/.config/proxy/config
```
Формат строгий: одна секция `[proxy]`, пять обязательных ключей без повторов и
неизвестных полей.
```ini
[proxy]
type = external
name = Hiddify
address = hproxy.local
port = 12334
no_proxy_extra = .example.internal,git.example.internal
```
`address` — IPv4, IPv6 без квадратных скобок или ASCII DNS-имя без схемы и
порта. `no_proxy_extra` — список через запятую; значение может быть пустым.
Поддержка CIDR в `NO_PROXY` различается между программами, поэтому надёжнее
указывать точные IP и домены.
Если файла нет, используется прежний внешний Hiddify на
`hproxy.local:12334`, включая `.concept-software.ru` в `NO_PROXY`. Это не
переключает тип прокси автоматически.
Для управляемого Xray задайте явный тип, loopback IP и нужный локальный порт:
```ini
[proxy]
type = xray
name = Local Xray
address = 127.0.0.1
port = 12334
no_proxy_extra = .example.internal,git.example.internal
```
Секретный профиль хранится отдельно. Скопируйте синтетический пример, замените
его одной настоящей VLESS-ссылкой и закройте права конечного файла:
```bash
cp ~/.config/xray-proxy/examples/default.url ~/.config/xray-proxy/default.url
chmod 600 ~/.config/xray-proxy/default.url
```
Файл должен содержать ровно одну непустую строку. Можно передать другой файл
через `xray-proxy start -f /path/to/profile.url`. Если это симлинк, проверяются
права конечного файла.
## Команды
```bash
xray-proxy status
xray-proxy start [-f PROFILE]
xray-proxy stop
xray-proxy env
xray-proxy run -- COMMAND ARG...
proxy_on
proxy_off
pclaude [ARG...]
pcodex [ARG...]
myip
```
Для `external` команды `status`, `env` и `run` проверяют настроенный адрес и
порт. Для `xray` они дополнительно требуют закрытое состояние именно того
супервизора и дочернего Xray, который запустил CLI. Один открытый порт или чужой
PID не принимается. `run` никогда не запускает Xray скрыто.
`start` поддерживает Xray `25.12.8` и `26.3.27`, сначала выполняет
`xray version` и `xray run -test`, затем остаётся на переднем плане. Используйте
отдельную панель tmux. После готовности один loopback-порт принимает HTTP и
SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные
файлы и возвращает исходный `start` в терминал. Команда `stop` работает и после
явного переключения машинного типа на `external`.
`run` передаёт восемь proxy-переменных только дочернему процессу и сохраняет
аргументы и код возврата. `proxy_on` атомарно применяет те же восемь переменных
к текущей Bash-сессии; `proxy_off` удаляет их.
Таймаут проверки задаётся положительным числом секунд:
```bash
export PROXY_PREFLIGHT_TIMEOUT=2
```
Значение по умолчанию — 2 секунды. `env` при успехе печатает только восемь строк
`KEY=value`; для обычной диагностики используйте `status` или `myip`.
## Поддерживаемые профили
Поддерживаются только VLESS TCP/RAW с REALITY и VLESS XHTTP с TLS. Неизвестные,
повторные и несовместимые query-параметры отклоняются. XHTTP поддерживает ALPN
`http/1.1`, `h2` и `h3`; `extra` должен быть JSON-объектом без повторных ключей.
Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и
`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но
обычная проверка сертификата остаётся включённой. Xray 25.12.8 предназначен
только для явной старой совместимости: согласованное `true` переносится как
`allowInsecure=true` с предупреждением об отключённой проверке сертификата;
`pcs` и `vcn` для этой версии запрещены. Автоматического перехода между
версиями или типами прокси нет.
Сводка скрывает UUID и ключи. Содержимое XHTTP `path` не показывается — только
его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге
`$XDG_STATE_HOME/xray-proxy` либо `~/.local/state/xray-proxy` и удаляются после
штатной остановки. Сбой диагностики внешнего IP через локальный HTTP-прокси
показывается как предупреждение и не останавливает уже готовый Xray.