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:
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 в РЕД ОС проверена до заявления поддержки, а недоступная среда
|
||||
явно записана как неподтверждённая вместо неподкреплённого обещания.
|
||||
- [ ] После приёмки статус исходной спецификации и документация соответствуют
|
||||
фактически подтверждённому объёму, включая все оставшиеся ограничения.
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 пакеты..."
|
||||
|
||||
@@ -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
@@ -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 не установлен"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+1123
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
^/README\.md$
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user