diff --git a/.scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md b/.scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md new file mode 100644 index 0000000..4aa5018 --- /dev/null +++ b/.scratch/headless-xray-proxy/issues/01-external-proxy-and-portable-shell.md @@ -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`. diff --git a/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md b/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md new file mode 100644 index 0000000..3aa5534 --- /dev/null +++ b/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md @@ -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-запрос. + Подмена порта или состояния чужим процессом даёт безопасный отказ. diff --git a/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md b/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md new file mode 100644 index 0000000..f581198 --- /dev/null +++ b/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md @@ -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, а также исчезнувший супервизор. diff --git a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md b/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md new file mode 100644 index 0000000..6d14e1a --- /dev/null +++ b/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md @@ -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`, атомарность и отсутствие + записи вне изолированного пользовательского каталога. diff --git a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md b/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md new file mode 100644 index 0000000..74f8e4e --- /dev/null +++ b/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md @@ -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 в РЕД ОС проверена до заявления поддержки, а недоступная среда + явно записана как неподтверждённая вместо неподкреплённого обещания. +- [ ] После приёмки статус исходной спецификации и документация соответствуют + фактически подтверждённому объёму, включая все оставшиеся ограничения. diff --git a/README.md b/README.md index 75558f6..4dd8c00 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/bash/.bash_custom b/bash/.bash_custom index f2fe1de..092c279 100644 --- a/bash/.bash_custom +++ b/bash/.bash_custom @@ -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). diff --git a/bash/.bash_functions b/bash/.bash_functions index 165bb8a..3571ca9 100644 --- a/bash/.bash_functions +++ b/bash/.bash_functions @@ -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)"' - diff --git a/docs/specs/2026-07-15-headless-xray-proxy-design.md b/docs/specs/2026-07-15-headless-xray-proxy-design.md index a682f30..2fee1de 100644 --- a/docs/specs/2026-07-15-headless-xray-proxy-design.md +++ b/docs/specs/2026-07-15-headless-xray-proxy-design.md @@ -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. diff --git a/install.sh b/install.sh index daf304a..10701f1 100755 --- a/install.sh +++ b/install.sh @@ -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 пакеты..." diff --git a/tests/test_install.sh b/tests/test_install.sh index dfedf89..8d6c2da 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -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" diff --git a/tests/test_proxy_launchers.sh b/tests/test_proxy_launchers.sh index 4b0d4a8..722da22 100755 --- a/tests/test_proxy_launchers.sh +++ b/tests/test_proxy_launchers.sh @@ -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 $'\n<-->\n\n\n<-->\n<--flag>' "$output" "pclaude исказил аргументы" + + set +e + output="$(pcodex --search)" + rc=$? + set -e + assert_eq 42 "$rc" "pcodex не сохранил код возврата" + assert_eq $'\n<-->\n\n<--search>' "$output" "pcodex исказил аргументы" +) && ok "pclaude и pcodex делегируют run и сохраняют аргументы/код" + +# 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 не установлен" diff --git a/tests/test_xray_proxy.py b/tests/test_xray_proxy.py new file mode 100644 index 0000000..22474a1 --- /dev/null +++ b/tests/test_xray_proxy.py @@ -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() diff --git a/tests/test_xray_proxy_managed.py b/tests/test_xray_proxy_managed.py new file mode 100644 index 0000000..7522d1e --- /dev/null +++ b/tests/test_xray_proxy_managed.py @@ -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() diff --git a/xray-proxy/.config/xray-proxy/examples/config b/xray-proxy/.config/xray-proxy/examples/config new file mode 100644 index 0000000..1a077ff --- /dev/null +++ b/xray-proxy/.config/xray-proxy/examples/config @@ -0,0 +1,6 @@ +[proxy] +type = external +name = Hiddify +address = hproxy.local +port = 12334 +no_proxy_extra = .example.internal,git.example.internal diff --git a/xray-proxy/.config/xray-proxy/examples/default.url b/xray-proxy/.config/xray-proxy/examples/default.url new file mode 100644 index 0000000..c74b1c2 --- /dev/null +++ b/xray-proxy/.config/xray-proxy/examples/default.url @@ -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 diff --git a/xray-proxy/.config/xray-proxy/shell.bash b/xray-proxy/.config/xray-proxy/shell.bash new file mode 100644 index 0000000..c2b2a6d --- /dev/null +++ b/xray-proxy/.config/xray-proxy/shell.bash @@ -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" +} diff --git a/xray-proxy/.local/bin/xray-proxy b/xray-proxy/.local/bin/xray-proxy new file mode 100755 index 0000000..ca24a7b --- /dev/null +++ b/xray-proxy/.local/bin/xray-proxy @@ -0,0 +1,1123 @@ +#!/usr/bin/env python3 +import sys + +if sys.version_info < (3, 8): + sys.stderr.write( + "Ошибка: xray-proxy требует Python 3.8 или новее; обнаружен Python {}.{}.\n".format( + sys.version_info[0], sys.version_info[1] + ) + ) + sys.exit(1) + +import argparse +import configparser +import hashlib +import http.client +import ipaddress +import json +import math +import os +import re +import shutil +import signal +import socket +import stat +import subprocess +import time +import uuid +from pathlib import Path +from urllib.parse import parse_qsl, unquote, urlsplit + + +CONFIG_KEYS = {"type", "name", "address", "port", "no_proxy_extra"} +DEFAULT_NAME = "Hiddify" +DEFAULT_ADDRESS = "hproxy.local" +DEFAULT_PORT = 12334 +DEFAULT_NO_PROXY_EXTRA = (".concept-software.ru",) +DNS_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +VERSION_LINE = re.compile(r"^Xray (25\.12\.8|26\.3\.27)(?: .*)?$") +QUERY_KEYS = { + "type", + "security", + "encryption", + "flow", + "sni", + "fp", + "pbk", + "sid", + "headerType", + "host", + "alpn", + "hiddify", + "core", + "path", + "mode", + "extra", + "pcs", + "vcn", + "allowInsecure", + "insecure", +} +ALPN_VALUES = {"http/1.1", "h2", "h3"} +STATE_KEYS = { + "supervisor_pid", + "xray_pid", + "xray_path", + "config_path", + "address", + "port", + "profile_fingerprint", +} + + +class ProxyError(Exception): + pass + + +class PreflightExpired(Exception): + pass + + +class ProxyConfig: + def __init__(self, proxy_type, name, address, port, no_proxy_extra): + self.proxy_type = proxy_type + self.name = name + self.address = address + self.port = port + self.no_proxy_extra = no_proxy_extra + + +class VlessProfile: + def __init__(self, server, port, profile_id, query): + self.server = server + self.port = port + self.profile_id = profile_id + self.query = query + + @property + def transport(self): + return self.query["type"] + + @property + def security(self): + return self.query["security"] + + +def config_path(): + config_home = os.environ.get("XDG_CONFIG_HOME") + if config_home: + return Path(config_home).expanduser() / "proxy/config" + return Path.home() / ".config/proxy/config" + + +def xray_config_home(): + config_home = os.environ.get("XDG_CONFIG_HOME") + if config_home: + return Path(config_home).expanduser() / "xray-proxy" + return Path.home() / ".config/xray-proxy" + + +def state_home(): + state_root = os.environ.get("XDG_STATE_HOME") + if state_root: + return Path(state_root).expanduser() / "xray-proxy" + return Path.home() / ".local/state/xray-proxy" + + +def state_path(): + return state_home() / "state.json" + + +def generated_config_path(): + return state_home() / "client.json" + + +def validate_address(value): + if not value or not value.isascii(): + raise ProxyError("поле address должно содержать IPv4, IPv6 или ASCII DNS-имя") + if any(character.isspace() for character in value): + raise ProxyError("поле address не должно содержать пробелы") + if any(separator in value for separator in ("%", "[", "]", "/", "\\", "@", "?", "#")): + raise ProxyError( + "поле address должно быть адресом без zone ID, скобок и URL-разделителей" + ) + + try: + return ipaddress.ip_address(value) + except ValueError: + pass + + if len(value) > 253: + raise ProxyError("DNS-имя в поле address слишком длинное") + labels = value.split(".") + if not labels or any(not DNS_LABEL.fullmatch(label) for label in labels): + raise ProxyError( + "поле address должно быть адресом без схемы, порта, скобок и URL-разделителей" + ) + return None + + +def parse_no_proxy_extra(value): + if value == "": + return () + items = tuple(item.strip() for item in value.split(",")) + if any(not item for item in items): + raise ProxyError("no_proxy_extra содержит пустой элемент") + return items + + +def default_config(): + return ProxyConfig( + "external", + DEFAULT_NAME, + DEFAULT_ADDRESS, + DEFAULT_PORT, + DEFAULT_NO_PROXY_EXTRA, + ) + + +def load_config(): + path = config_path() + if not path.exists(): + if path.is_symlink(): + raise ProxyError("машинный конфиг ссылается на отсутствующий файл: {}".format(path)) + return default_config() + if not path.is_file(): + raise ProxyError("машинный конфиг не является обычным файлом: {}".format(path)) + + try: + raw = path.read_bytes() + except OSError as error: + raise ProxyError("не удалось прочитать машинный конфиг {}: {}".format(path, error)) + if b"\0" in raw: + raise ProxyError("машинный конфиг содержит запрещённый NUL") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + raise ProxyError("машинный конфиг должен быть в UTF-8") + + parser = configparser.ConfigParser( + interpolation=None, + strict=True, + allow_no_value=False, + empty_lines_in_values=False, + ) + parser.optionxform = str + try: + parser.read_string(text, source=str(path)) + except configparser.Error as error: + raise ProxyError("неверный машинный INI: {}".format(error)) + + if parser.defaults(): + raise ProxyError("машинный INI не допускает секцию [DEFAULT]") + if parser.sections() != ["proxy"]: + raise ProxyError("машинный INI должен содержать только секцию [proxy]") + + section = parser["proxy"] + actual_keys = set(section.keys()) + if actual_keys != CONFIG_KEYS: + missing = sorted(CONFIG_KEYS - actual_keys) + unknown = sorted(actual_keys - CONFIG_KEYS) + details = [] + if missing: + details.append("нет ключей: {}".format(", ".join(missing))) + if unknown: + details.append("неизвестные ключи: {}".format(", ".join(unknown))) + raise ProxyError("неверные ключи [proxy] ({})".format("; ".join(details))) + + values = {key: section.get(key, raw=True) for key in CONFIG_KEYS} + if any("\n" in value or "\r" in value or "\0" in value for value in values.values()): + raise ProxyError("многострочные значения в машинном INI запрещены") + + proxy_type = values["type"].strip() + if proxy_type not in ("external", "xray"): + raise ProxyError("поле type должно быть external или xray") + name = values["name"].strip() + address = values["address"].strip() + if not name: + raise ProxyError("поле name не должно быть пустым") + if not address: + raise ProxyError("поле address не должно быть пустым") + validate_address(address) + + port_text = values["port"].strip() + if not re.fullmatch(r"[0-9]+", port_text): + raise ProxyError("поле port должно быть целым числом от 1 до 65535") + port = int(port_text) + if not 1 <= port <= 65535: + raise ProxyError("поле port должно быть в диапазоне 1–65535") + + no_proxy_extra = parse_no_proxy_extra(values["no_proxy_extra"]) + return ProxyConfig(proxy_type, name, address, port, no_proxy_extra) + + +def preflight_timeout(): + raw = os.environ.get("PROXY_PREFLIGHT_TIMEOUT", "2") + try: + timeout = float(raw) + except ValueError: + raise ProxyError("PROXY_PREFLIGHT_TIMEOUT должен быть положительным числом секунд") + if not math.isfinite(timeout) or timeout <= 0: + raise ProxyError("PROXY_PREFLIGHT_TIMEOUT должен быть положительным числом секунд") + return timeout + + +def check_reachable(config, timeout): + def expire_preflight(_signal_number, _frame): + raise PreflightExpired() + + connection = None + previous_handler = signal.signal(signal.SIGALRM, expire_preflight) + try: + signal.setitimer(signal.ITIMER_REAL, timeout) + connection = socket.create_connection((config.address, config.port), timeout=timeout) + signal.setitimer(signal.ITIMER_REAL, 0) + except PreflightExpired: + raise ProxyError( + "прокси {} ({}:{}) недоступен: таймаут {} с".format( + config.name, config.address, config.port, timeout + ) + ) + except (OverflowError, ValueError) as error: + raise ProxyError( + "PROXY_PREFLIGHT_TIMEOUT={} нельзя применить на этой платформе: {}".format( + timeout, error + ) + ) + except OSError as error: + raise ProxyError( + "прокси {} ({}:{}) недоступен: {}".format( + config.name, config.address, config.port, error + ) + ) + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous_handler) + if connection is not None: + connection.close() + + +def check_endpoint(address, port, name="управляемый Xray"): + config = ProxyConfig("xray", name, address, port, ()) + check_reachable(config, preflight_timeout()) + + +def proxy_host_for_url(address): + try: + parsed = ipaddress.ip_address(address) + except ValueError: + return address + if parsed.version == 6: + return "[{}]".format(address) + return address + + +def environment_values(config): + host = proxy_host_for_url(config.address) + http_url = "http://{}:{}".format(host, config.port) + socks_url = "socks5://{}:{}".format(host, config.port) + no_proxy_items = [config.address, "localhost", "127.0.0.1", "::1", "[::1]"] + no_proxy_items.extend(config.no_proxy_extra) + no_proxy = ",".join(dict.fromkeys(no_proxy_items)) + return [ + ("HTTP_PROXY", http_url), + ("HTTPS_PROXY", http_url), + ("ALL_PROXY", socks_url), + ("NO_PROXY", no_proxy), + ("http_proxy", http_url), + ("https_proxy", http_url), + ("all_proxy", socks_url), + ("no_proxy", no_proxy), + ] + + +def private_directory(path): + path.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + path.chmod(0o700) + except OSError as error: + raise ProxyError("не удалось закрыть служебный каталог: {}".format(error)) + + +def write_private_json(path, value): + private_directory(path.parent) + temporary = path.with_name(path.name + ".tmp.{}".format(os.getpid())) + descriptor = None + try: + descriptor = os.open( + str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + descriptor = None + json.dump( + value, + stream, + ensure_ascii=False, + sort_keys=True, + allow_nan=False, + ) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(str(temporary), str(path)) + path.chmod(0o600) + except (OSError, ValueError) as error: + raise ProxyError("не удалось записать закрытый служебный файл: {}".format(error)) + finally: + if descriptor is not None: + os.close(descriptor) + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def read_profile(path): + try: + resolved = path.expanduser().resolve(strict=True) + file_stat = resolved.stat() + except (OSError, RuntimeError) as error: + raise ProxyError("не удалось открыть файл профиля: {}".format(error)) + if not stat.S_ISREG(file_stat.st_mode): + raise ProxyError("файл профиля должен быть обычным файлом") + if file_stat.st_mode & 0o077: + raise ProxyError( + "конечный файл профиля доступен группе или другим пользователям; выполните chmod 600" + ) + try: + raw = resolved.read_bytes() + except OSError as error: + raise ProxyError("не удалось прочитать файл профиля: {}".format(error)) + if b"\0" in raw: + raise ProxyError("файл профиля содержит запрещённый NUL") + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + raise ProxyError("файл профиля должен быть в UTF-8") + if text.endswith("\n"): + text = text[:-1] + if not text or "\n" in text or "\r" in text or text != text.strip(): + raise ProxyError("файл профиля должен содержать ровно одну непустую VLESS-ссылку") + return text + + +def parse_query(raw_query): + if re.search(r"%(?![0-9A-Fa-f]{2})", raw_query): + raise ProxyError("профиль содержит неверное URL-кодирование") + try: + pairs = parse_qsl( + raw_query, + keep_blank_values=True, + strict_parsing=True, + encoding="utf-8", + errors="strict", + ) + except (UnicodeError, ValueError): + raise ProxyError("профиль содержит неверную строку параметров") + query = {} + for key, value in pairs: + if any(ord(character) < 32 or ord(character) == 127 for character in key + value): + raise ProxyError("профиль содержит управляющий символ") + if key in query: + raise ProxyError("профиль содержит повтор query-параметра") + if key not in QUERY_KEYS: + raise ProxyError("профиль содержит неизвестный query-параметр") + query[key] = value + return query + + +def required(query, *keys): + missing = [key for key in keys if not query.get(key)] + if missing: + raise ProxyError("в профиле отсутствуют обязательные параметры") + + +def parse_boolean_fields(query): + values = [] + for key in ("allowInsecure", "insecure"): + if key in query: + if query[key] not in ("true", "false"): + raise ProxyError("legacy-поля принимают только true или false") + values.append(query[key] == "true") + if len(values) == 2 and values[0] != values[1]: + raise ProxyError("allowInsecure и insecure должны совпадать") + return values[0] if values else False + + +def parse_json_object(value): + def reject_duplicates(pairs): + result = {} + for key, item in pairs: + if key in result: + raise ProxyError("extra содержит повтор ключа JSON") + result[key] = item + return result + + def reject_constant(_constant): + raise ProxyError("extra содержит константу вне стандарта JSON") + + try: + parsed = json.loads( + value, + object_pairs_hook=reject_duplicates, + parse_constant=reject_constant, + ) + except ProxyError: + raise + except (TypeError, ValueError): + raise ProxyError("extra должен быть корректным JSON-объектом") + if not isinstance(parsed, dict): + raise ProxyError("extra должен быть JSON-объектом") + return parsed + + +def parse_vless(url, version): + try: + parsed = urlsplit(url) + except ValueError: + raise ProxyError("неверная VLESS-ссылка") + if ( + parsed.scheme != "vless" + or not parsed.netloc + or parsed.password is not None + or parsed.path + ): + raise ProxyError("ожидалась одна ссылка vless://UUID@SERVER:PORT") + try: + profile_id = unquote(parsed.username or "", encoding="utf-8", errors="strict") + server = parsed.hostname + port = parsed.port + except (UnicodeError, ValueError): + raise ProxyError("неверные UUID, сервер или порт VLESS") + try: + parsed_uuid = uuid.UUID(profile_id) + except ValueError: + raise ProxyError("профиль содержит неверный UUID") + if str(parsed_uuid) != profile_id.lower() or not server or port is None: + raise ProxyError("профиль содержит неверные UUID, сервер или порт") + validate_address(server) + query = parse_query(parsed.query) + required(query, "type", "security", "encryption", "sni", "fp") + if query["encryption"] != "none": + raise ProxyError("поддерживается только encryption=none") + if query.get("flow") not in (None, "xtls-rprx-vision"): + raise ProxyError("поддерживается только flow=xtls-rprx-vision") + if query.get("headerType", "none") != "none": + raise ProxyError("поддерживается только headerType=none") + if "hiddify" in query and query["hiddify"] != "1": + raise ProxyError("поддерживается только метаданные hiddify=1") + if "core" in query and query["core"] != "xray": + raise ProxyError("поддерживается только метаданные core=xray") + legacy_insecure = parse_boolean_fields(query) + + if query["type"] == "tcp" and query["security"] == "reality": + required(query, "flow", "pbk", "sid") + forbidden = {"path", "mode", "extra", "pcs", "vcn", "core", "allowInsecure", "insecure"} + if forbidden.intersection(query): + raise ProxyError("параметры профиля несовместимы с TCP/REALITY") + elif query["type"] == "xhttp" and query["security"] == "tls": + required(query, "host", "path") + if "flow" in query or "pbk" in query or "sid" in query: + raise ProxyError("параметры профиля несовместимы с XHTTP/TLS") + if version == "25.12.8" and ("pcs" in query or "vcn" in query): + raise ProxyError("pcs/vcn несовместимы с Xray 25.12.8") + if "pcs" in query: + hashes = list_value(query["pcs"]) + if any(re.fullmatch(r"[0-9A-Fa-f]{64}", item) is None for item in hashes): + raise ProxyError("pcs должен содержать 32-байтные SHA-256 в hex") + if "vcn" in query: + list_value(query["vcn"]) + if "alpn" in query: + alpn = query["alpn"].split(",") + if not alpn or any(item not in ALPN_VALUES for item in alpn): + raise ProxyError("профиль содержит неподдерживаемый ALPN") + if "extra" in query: + parse_json_object(query["extra"]) + else: + raise ProxyError("поддерживаются только TCP/REALITY и XHTTP/TLS") + + profile = VlessProfile(server, port, profile_id, query) + profile.legacy_insecure = legacy_insecure + return profile + + +def list_value(value): + items = value.split(",") + if any(not item for item in items): + raise ProxyError("списочный параметр содержит пустой элемент") + return items + + +def build_xray_config(profile, local_address, local_port, version): + query = profile.query + settings = { + "address": profile.server, + "port": profile.port, + "id": profile.profile_id, + "encryption": query["encryption"], + } + if "flow" in query: + settings["flow"] = query["flow"] + if profile.transport == "tcp": + reality = { + "serverName": query["sni"], + "fingerprint": query["fp"], + "password": query["pbk"], + "shortId": query["sid"], + } + stream = { + "network": "raw", + "security": "reality", + "realitySettings": reality, + } + else: + xhttp = {"host": query["host"], "path": query["path"]} + if "mode" in query: + xhttp["mode"] = query["mode"] + if "extra" in query: + xhttp["extra"] = parse_json_object(query["extra"]) + tls = {"serverName": query["sni"], "fingerprint": query["fp"]} + if "alpn" in query: + tls["alpn"] = list_value(query["alpn"]) + if version == "26.3.27": + if "pcs" in query: + tls["pinnedPeerCertSha256"] = query["pcs"] + if "vcn" in query: + tls["verifyPeerCertByName"] = query["vcn"] + elif profile.legacy_insecure: + tls["allowInsecure"] = True + stream = { + "network": "xhttp", + "security": "tls", + "xhttpSettings": xhttp, + "tlsSettings": tls, + } + return { + "log": {"access": "none", "loglevel": "warning"}, + "inbounds": [ + { + "tag": "local-proxy", + "listen": local_address, + "port": local_port, + "protocol": "socks", + "settings": {"auth": "noauth", "udp": True}, + } + ], + "outbounds": [ + { + "tag": "proxy", + "protocol": "vless", + "settings": settings, + "streamSettings": stream, + } + ], + } + + +def resolve_xray(): + found = shutil.which("xray") + if found is None: + raise ProxyError("xray не найден в PATH; сначала выполните xray-proxy install") + try: + return str(Path(found).resolve(strict=True)) + except OSError as error: + raise ProxyError("не удалось разрешить путь к xray: {}".format(error)) + + +def xray_version(path): + try: + completed = subprocess.run( + [path, "version"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=5, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + raise ProxyError("не удалось безопасно определить версию выбранного xray") + first_line = completed.stdout.splitlines()[0] if completed.stdout.splitlines() else "" + match = VERSION_LINE.fullmatch(first_line) + if completed.returncode != 0 or match is None: + raise ProxyError("выбранный xray не сообщил точно поддерживаемую версию") + return match.group(1) + + +def run_xray_test(path, config_path): + try: + completed = subprocess.run( + [path, "run", "-test", "-c", str(config_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + raise ProxyError("проверка созданной конфигурации Xray не выполнена") + if completed.returncode != 0: + raise ProxyError("созданная конфигурация не прошла xray run -test") + + +def process_alive(pid): + try: + stat_fields = Path("/proc/{}/stat".format(pid)).read_text(encoding="ascii").split() + if len(stat_fields) > 2 and stat_fields[2] == "Z": + return False + os.kill(pid, 0) + return True + except (OSError, ValueError): + return False + + +def process_cmdline(pid): + try: + raw = Path("/proc/{}/cmdline".format(pid)).read_bytes() + except OSError: + return [] + return [item.decode("utf-8", "surrogateescape") for item in raw.split(b"\0") if item] + + +def process_parent(pid): + try: + for line in Path("/proc/{}/status".format(pid)).read_text(encoding="ascii").splitlines(): + if line.startswith("PPid:"): + return int(line.split()[1]) + except (OSError, ValueError): + pass + return None + + +def process_argument_resolves_to(pid, arguments, expected_path): + try: + process_cwd = Path("/proc/{}/cwd".format(pid)).resolve(strict=True) + except (OSError, RuntimeError): + return False + for argument in arguments: + if not argument or "\0" in argument: + continue + candidate = Path(argument) + if not candidate.is_absolute(): + candidate = process_cwd / candidate + try: + if candidate.resolve(strict=True) == expected_path: + return True + except (OSError, RuntimeError): + continue + return False + + +def process_socket_inodes(pid): + inodes = set() + try: + descriptors = Path("/proc/{}/fd".format(pid)).iterdir() + for descriptor in descriptors: + try: + target = os.readlink(str(descriptor)) + except OSError: + continue + match = re.fullmatch(r"socket:\[([0-9]+)\]", target) + if match is not None: + inodes.add(match.group(1)) + except OSError: + return set() + return inodes + + +def decode_proc_address(encoded): + raw = bytes.fromhex(encoded) + if len(raw) == 4: + raw = raw[::-1] + elif len(raw) == 16: + raw = b"".join(raw[index : index + 4][::-1] for index in range(0, 16, 4)) + else: + raise ValueError("unexpected proc address size") + return ipaddress.ip_address(raw) + + +def process_owns_listener(pid, address, port): + inodes = process_socket_inodes(pid) + if not inodes: + return False + expected_address = ipaddress.ip_address(address) + for table_name in ("tcp", "tcp6"): + table = Path("/proc/{}/net/{}".format(pid, table_name)) + try: + lines = table.read_text(encoding="ascii").splitlines()[1:] + except OSError: + continue + for line in lines: + fields = line.split() + if len(fields) < 10 or fields[3] != "0A" or fields[9] not in inodes: + continue + encoded_address, encoded_port = fields[1].split(":", 1) + try: + local_address = decode_proc_address(encoded_address) + local_port = int(encoded_port, 16) + except (ValueError, TypeError): + continue + if local_address == expected_address and local_port == port: + return True + return False + + +def read_managed_state(): + path = state_path() + try: + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise ProxyError("состояние управляемого Xray не подтверждено") + if info.st_uid != os.getuid() or info.st_mode & 0o077: + raise ProxyError("состояние управляемого Xray не подтверждено") + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise ProxyError("управляемый Xray не запущен; выполните xray-proxy start") + except ProxyError: + raise + except (OSError, ValueError, TypeError): + raise ProxyError("состояние управляемого Xray не подтверждено") + if not isinstance(value, dict) or set(value) != STATE_KEYS: + raise ProxyError("состояние управляемого Xray не подтверждено") + if not all(isinstance(value[key], int) and not isinstance(value[key], bool) for key in ("supervisor_pid", "xray_pid", "port")): + raise ProxyError("состояние управляемого Xray не подтверждено") + if not all(isinstance(value[key], str) for key in ("xray_path", "config_path", "address", "profile_fingerprint")): + raise ProxyError("состояние управляемого Xray не подтверждено") + return value + + +def verify_managed_state(config=None, require_port=True): + state = read_managed_state() + supervisor = state["supervisor_pid"] + child = state["xray_pid"] + if supervisor <= 1 or child <= 1 or supervisor == child: + raise ProxyError("личность управляемого Xray не подтверждена") + supervisor_args = process_cmdline(supervisor) + child_args = process_cmdline(child) + cli = Path(__file__).resolve() + if ( + not process_alive(supervisor) + or not process_argument_resolves_to(supervisor, supervisor_args, cli) + or "start" not in supervisor_args + or not process_alive(child) + or state["xray_path"] not in child_args + or "run" not in child_args + or "-c" not in child_args + or state["config_path"] not in child_args + or process_parent(child) != supervisor + or not process_owns_listener(child, state["address"], state["port"]) + ): + raise ProxyError( + "личность процесса или listener управляемого Xray не подтверждены" + ) + if config is not None and ( + config.proxy_type != "xray" + or config.address != state["address"] + or config.port != state["port"] + ): + raise ProxyError("настройки и состояние управляемого Xray не совпадают") + if require_port: + check_endpoint(state["address"], state["port"]) + return state + + +def safe_summary(profile, local_port, xray_path, version): + query = profile.query + print("Xray: {} ({})".format(xray_path, version), flush=True) + print( + "Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format( + profile.server, + profile.port, + "RAW" if profile.transport == "tcp" else "XHTTP", + profile.security.upper(), + query["sni"], + query["fp"], + local_port, + ), + flush=True, + ) + if profile.transport == "tcp": + if "host" in query or "alpn" in query: + print("TCP/REALITY: Host и ALPN из ссылки не используются", flush=True) + print("Секреты REALITY: скрыты", flush=True) + else: + print( + "XHTTP: Host {}, длина пути: {}, ALPN {}".format( + query["host"], len(query["path"]), query.get("alpn", "не задан") + ), + flush=True, + ) + print("UUID и ключи: скрыты", flush=True) + + +def warn_legacy(profile, version): + if version == "26.3.27" and profile.legacy_insecure: + sys.stderr.write( + "Предупреждение: legacy insecure не переносится; сертификат проверяется обычным способом.\n" + ) + if version == "25.12.8" and profile.legacy_insecure: + sys.stderr.write( + "Предупреждение: Xray 25.12.8 использует allowInsecure=true; проверка сертификата отключена.\n" + ) + + +def diagnose_ip(config): + connection = http.client.HTTPConnection( + config.address, config.port, timeout=preflight_timeout() + ) + try: + connection.request( + "GET", + "http://ifconfig.me/ip", + headers={"Host": "ifconfig.me", "User-Agent": "xray-proxy/1"}, + ) + response = connection.getresponse() + address = response.read(256).decode("ascii", "replace").strip() + if response.status != 200 or not address: + raise ValueError("empty response") + parsed_address = ipaddress.ip_address(address) + print("Внешний IP через прокси: {}".format(parsed_address), flush=True) + except (OSError, ValueError, http.client.HTTPException): + sys.stderr.write( + "Предупреждение: внешний IP через прокси определить не удалось; Xray продолжает работу.\n" + ) + finally: + connection.close() + + +def cleanup_managed_files(supervisor_pid): + try: + current = read_managed_state() + if current["supervisor_pid"] == supervisor_pid: + state_path().unlink() + except ProxyError: + pass + try: + generated_config_path().unlink() + except FileNotFoundError: + pass + + +def command_status(config): + if config.proxy_type == "external": + check_reachable(config, preflight_timeout()) + print( + "{} (external) доступен по адресу {}:{}".format( + config.name, config.address, config.port + ) + ) + else: + verify_managed_state(config) + print( + "{}: управляемый Xray работает на {}:{}".format( + config.name, config.address, config.port + ) + ) + return 0 + + +def command_env(config): + if config.proxy_type == "external": + check_reachable(config, preflight_timeout()) + else: + verify_managed_state(config) + lines = ["{}={}".format(key, value) for key, value in environment_values(config)] + sys.stdout.write("\n".join(lines) + "\n") + return 0 + + +def command_run(config, remainder): + if not remainder or remainder[0] != "--" or len(remainder) == 1: + raise ProxyError("использование: xray-proxy run -- COMMAND ARG...") + command = remainder[1:] + if shutil.which(command[0]) is None: + sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) + return 127 + + if config.proxy_type == "external": + check_reachable(config, preflight_timeout()) + else: + verify_managed_state(config) + child_environment = os.environ.copy() + child_environment.update(dict(environment_values(config))) + try: + completed = subprocess.run(command, env=child_environment, check=False) + except FileNotFoundError: + sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) + return 127 + except OSError as error: + sys.stderr.write("Ошибка: не удалось запустить {}: {}\n".format(command[0], error)) + return 126 + if completed.returncode < 0: + return 128 - completed.returncode + return completed.returncode + + +def command_start(config, profile_file): + if config.proxy_type == "external": + raise ProxyError("start доступен только для type=xray; измените машинный конфиг") + try: + address = ipaddress.ip_address(config.address) + except ValueError: + raise ProxyError("для type=xray поле address должно быть loopback IP-адресом") + if not address.is_loopback: + raise ProxyError("для type=xray требуется loopback IP-адрес") + if state_path().exists() or state_path().is_symlink(): + raise ProxyError("файл состояния уже существует; управляемый запуск отменён") + + profile_path = Path(profile_file) if profile_file else xray_config_home() / "default.url" + raw_profile = read_profile(profile_path) + executable = resolve_xray() + version = xray_version(executable) + profile = parse_vless(raw_profile, version) + generated = build_xray_config(profile, config.address, config.port, version) + write_private_json(generated_config_path(), generated) + try: + run_xray_test(executable, generated_config_path()) + safe_summary(profile, config.port, executable, version) + warn_legacy(profile, version) + stopping = {"signal": None} + + def request_stop(signal_number, _frame): + if stopping["signal"] is None: + stopping["signal"] = signal_number + + previous = {} + for handled in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + previous[handled] = signal.signal(handled, request_stop) + child = subprocess.Popen( + [executable, "run", "-c", str(generated_config_path())] + ) + state = { + "supervisor_pid": os.getpid(), + "xray_pid": child.pid, + "xray_path": executable, + "config_path": str(generated_config_path()), + "address": config.address, + "port": config.port, + "profile_fingerprint": hashlib.sha256(raw_profile.encode("utf-8")).hexdigest(), + } + write_private_json(state_path(), state) + deadline = time.monotonic() + max(1.0, preflight_timeout()) + last_error = None + while time.monotonic() < deadline: + if child.poll() is not None: + raise ProxyError("Xray завершился до готовности локального порта") + try: + check_endpoint(config.address, config.port) + if not process_owns_listener(child.pid, config.address, config.port): + raise ProxyError( + "настроенный listener не принадлежит дочернему Xray" + ) + last_error = None + break + except ProxyError as error: + last_error = error + time.sleep(0.03) + if last_error is not None: + raise ProxyError( + "дочерний Xray не подтвердил владение настроенным listener" + ) + diagnose_ip(config) + while child.poll() is None and stopping["signal"] is None: + time.sleep(0.05) + if stopping["signal"] is not None and child.poll() is None: + os.kill(child.pid, stopping["signal"]) + try: + child.wait(timeout=stop_timeout()) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + return_code = child.wait() + if stopping["signal"] is not None: + return 0 + if return_code < 0: + return 128 - return_code + return return_code + finally: + if "previous" in locals(): + for handled, handler in previous.items(): + signal.signal(handled, handler) + if "child" in locals() and child.poll() is None: + child.terminate() + try: + child.wait(timeout=stop_timeout()) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + cleanup_managed_files(os.getpid()) + + +def stop_timeout(): + raw = os.environ.get("XRAY_PROXY_STOP_TIMEOUT", "5") + try: + value = float(raw) + except ValueError: + raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд") + if not math.isfinite(value) or value <= 0: + raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд") + return value + + +def command_stop(): + timeout = stop_timeout() + state = verify_managed_state(require_port=True) + try: + os.kill(state["supervisor_pid"], signal.SIGTERM) + except OSError: + raise ProxyError("не удалось послать сигнал подтверждённому супервизору") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if ( + not process_alive(state["supervisor_pid"]) + and not process_alive(state["xray_pid"]) + and not state_path().exists() + ): + print("Управляемый Xray остановлен") + return 0 + time.sleep(0.05) + raise ProxyError("штатная остановка управляемого Xray превысила таймаут") + + +def build_parser(): + parser = argparse.ArgumentParser( + prog="xray-proxy", + description="Безопасный запуск команд через явно настроенный прокси.", + ) + subparsers = parser.add_subparsers(dest="subcommand") + subparsers.add_parser("status", help="проверить доступность активного прокси") + subparsers.add_parser("env", help="вывести восемь proxy-переменных") + start_parser = subparsers.add_parser("start", help="запустить управляемый Xray") + start_parser.add_argument("-f", "--file", dest="profile_file") + subparsers.add_parser("stop", help="остановить управляемый Xray") + run_parser = subparsers.add_parser("run", help="запустить команду через прокси") + run_parser.add_argument("arguments", nargs=argparse.REMAINDER) + return parser + + +def main(): + parser = build_parser() + arguments = parser.parse_args() + if arguments.subcommand is None: + parser.print_usage(sys.stderr) + sys.stderr.write("xray-proxy: ошибка: укажите явную подкоманду\n") + return 2 + + try: + if arguments.subcommand == "stop": + return command_stop() + config = load_config() + if arguments.subcommand == "status": + return command_status(config) + if arguments.subcommand == "env": + return command_env(config) + if arguments.subcommand == "run": + return command_run(config, arguments.arguments) + if arguments.subcommand == "start": + return command_start(config, arguments.profile_file) + except ProxyError as error: + sys.stderr.write("Ошибка: {}\n".format(error)) + return 1 + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/xray-proxy/.stow-local-ignore b/xray-proxy/.stow-local-ignore new file mode 100644 index 0000000..8d287b5 --- /dev/null +++ b/xray-proxy/.stow-local-ignore @@ -0,0 +1 @@ +^/README\.md$ diff --git a/xray-proxy/README.md b/xray-proxy/README.md new file mode 100644 index 0000000..927e0fd --- /dev/null +++ b/xray-proxy/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.