refactor(xray-proxy): срезана accidental-сложность CLI, тестов и спеки

- Зачем:
  - CLI и спека были переусложнены относительно принятой модели угроз
    (один пользователь, доверенная машина): лишняя ось версий Xray и
    защита от входов, которые не возникают при ручном редактировании.
- Что:
  - убрана поддержка Xray 25.12.8 — поддерживается только 26.3.27;
    allowInsecure валидируется, но никогда не эмитится в JSON.
  - удалена проверка владения сокетом через /proc/net/tcp; идентичность
    процесса и сверка адрес/порт со state сохранены.
  - валидация INI/профиля/адреса/query сведена к отказам на реальные
    опечатки; scoped IPv6 отклоняется одинаково на Python 3.8 и 3.9+.
  - удалены fsync/O_EXCL-церемония, SIGALRM-таймер, zombie-инспекция,
    мёртвый отпечаток профиля и двойной разбор extra/alpn.
  - shell.bash принимает вывод env без ре-валидации, сохранив атомарность;
    спека и issue 01–05 приведены к новому объёму (тикет 06, леджер).
- Проверка:
  - python3 -m unittest discover -s tests — 34 теста OK.
  - bash tests/test_proxy_launchers.sh — 8 проверок PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Fable 5 committed 2026-07-20 04:13:54 -04:00
1 parent 71e16a795a
commit 98bfd6338a
12 files changed
+375 -499

No files matched your search

@@ -9,6 +9,8 @@ Hiddify. Привычные Bash-команды переходят на един
**Status:** done **Status:** done
Часть требований пересмотрена в 06 — см. 06-simplification-pass.md.
- [x] CLI работает на Python 3.8+ без сторонних библиотек, ничего не устанавливает - [x] CLI работает на Python 3.8+ без сторонних библиотек, ничего не устанавливает
и не запускает без явной подкоманды и выдаёт понятную ошибку на более старом и не запускает без явной подкоманды и выдаёт понятную ошибку на более старом
Python. Python.
@@ -9,6 +9,8 @@
**Status:** done **Status:** done
Часть требований пересмотрена в 06 — см. 06-simplification-pass.md.
- [x] `start` разрешён только при явном типе `xray`, требует loopback-адрес и - [x] `start` разрешён только при явном типе `xray`, требует loopback-адрес и
согласованный локальный порт, никогда не скачивает бинарник и выбирает первый согласованный локальный порт, никогда не скачивает бинарник и выбирает первый
`xray` из `PATH`. Один разрешённый путь используется для `version`, `xray` из `PATH`. Один разрешённый путь используется для `version`,
@@ -4,16 +4,20 @@
сигналы, устаревшее состояние и аварийное исчезновение супервизора, не затрагивая сигналы, устаревшее состояние и аварийное исчезновение супервизора, не затрагивая
чужие процессы и не оставляя секретные или временные данные. чужие процессы и не оставляя секретные или временные данные.
**Blocked by:** 02 — Управляемый Xray: полный штатный путь. **Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение.
**Status:** ready-for-agent **Status:** ready-for-agent
**Мандат идентичности:** идентичность = PID + время старта (`/proc/PID/stat`) +
исполняемый файл ребёнка / путь скрипта супервизора; текущий argv-обход заменить
этим механизмом.
- [ ] Супервизор владеет пользовательской блокировкой, допускает только один - [ ] Супервизор владеет пользовательской блокировкой, допускает только один
управляемый экземпляр и не заменяет запущенный профиль другим без явных управляемый экземпляр и не заменяет запущенный профиль другим без явных
`stop` и нового `start`. `stop` и нового `start`.
- [ ] Состояние с правами `0600` хранит PID и время старта супервизора и Xray, - [ ] Состояние с правами `0600` хранит PID и время старта супервизора и Xray,
путь к бинарнику, порт и безопасный отпечаток профиля, но не ссылку или её путь к бинарнику и порт, но не ссылку или её секреты; служебные каталоги имеют
секреты; служебные каталоги имеют права `0700`. права `0700`.
- [ ] `status` и `stop` сверяют PID со временем старта и ожидаемым исполняемым - [ ] `status` и `stop` сверяют PID со временем старта и ожидаемым исполняемым
файлом, поэтому чужой, устаревший и повторно использованный PID никогда не файлом, поэтому чужой, устаревший и повторно использованный PID никогда не
считается управляемым процессом. считается управляемым процессом.
@@ -19,17 +19,16 @@
- [ ] Основная инструкция объясняет внешний Hiddify и управляемый Xray, - [ ] Основная инструкция объясняет внешний Hiddify и управляемый Xray,
подготовку двух файлов настройки, все команды управления, `proxy_on/off`, подготовку двух файлов настройки, все команды управления, `proxy_on/off`,
`pclaude/pcodex`, `NO_PROXY`, ограничения CIDR и диагностику. `pclaude/pcodex`, `NO_PROXY`, ограничения CIDR и диагностику.
- [ ] Инструкция фиксирует две поддержанные семьи VLESS, современную проверку - [ ] Инструкция фиксирует две поддержанные семьи VLESS, Xray 26.3.27,
сертификата, явный запасной Xray 25.12.8 и отсутствие systemd, автозапуска и современную проверку сертификата и отсутствие systemd, автозапуска и
автоматического перехода между типами прокси. автоматического перехода между типами прокси.
- [ ] Корневой обзор кратко описывает подсистему, показывает подключение пакета - [ ] Корневой обзор кратко описывает подсистему, показывает подключение пакета
и направляет к полной инструкции, не дублируя её. и направляет к полной инструкции, не дублируя её.
- [ ] Полный автоматический прогон подтверждает Python 3.8-совместимый синтаксис, - [ ] Полный автоматический прогон подтверждает Python 3.8-совместимый синтаксис,
внешний Hiddify-сценарий, обе семьи VLESS, обе версии Xray, установку, внешний Hiddify-сценарий, обе семьи VLESS, Xray 26.3.27, установку,
жизненный цикл, Bash-интеграцию и отсутствие утечки секретов. жизненный цикл, Bash-интеграцию и отсутствие утечки секретов.
- [ ] На доступной Ubuntu с Python 3.10 проверены установка, `xray run -test` и - [ ] На доступной Ubuntu с Python 3.10 проверены установка, `xray run -test` и
работа HTTP и SOCKS через один локальный вход для заявленных совместимых работа HTTP и SOCKS через один локальный вход для Xray 26.3.27.
сочетаний Xray 26.3.27 и 25.12.8.
- [ ] На целевой удалённой системе выполнен тот же пользовательский сценарий; - [ ] На целевой удалённой системе выполнен тот же пользовательский сценарий;
версия Python в РЕД ОС проверена до заявления поддержки, а недоступная среда версия Python в РЕД ОС проверена до заявления поддержки, а недоступная среда
явно записана как неподтверждённая вместо неподкреплённого обещания. явно записана как неподтверждённая вместо неподкреплённого обещания.
@@ -0,0 +1,180 @@
# 06 — Упрощение: срез accidental-сложности спеки и кода
**What to build:** Один проход упрощения по готовым issue 01–02 и спеке:
убрать поддержку второй версии Xray, избыточные защитные проверки вне
принятой модели угроз («доверенные пользователи машины», единственный
пользователь редактирует свои файлы сам) и церемонии, не нужные личному
foreground-инструменту. Поведенческие контракты из раздела «Не трогать»
сохраняются без изменений.
**Blocked by:** 02 — Управляемый Xray: полный штатный путь.
**Status:** done
**Резолюция (2026-07-20):** реализовано и принято. Дельта: CLI 1123→976,
shell.bash 116→91, тесты 1357→1236 строк; сьюты: 34 unittest OK + 8 shell
PASS. Ревью: 1 MAJOR (scoped IPv6 в упрощённом валидаторе — починен с
регрессионным тестом под Python 3.8), 1 NIT отклонён аргументированно.
Non-gating замечания ревью приняты как риски модели угроз.
**Сначала прочитать:**
1. Этот файл целиком, затем `/tmp/xray-simplify-Zeo5/06-simplify/sweep.md` —
там точный line-level blast radius каждого пункта (секция 2 — срезы A/B,
секция 3 — SIMP-N). Номера строк — ориентир, сверяй с фактическим кодом.
2. `docs/specs/2026-07-15-headless-xray-proxy-design.md` — исходная спека.
3. `xray-proxy/.local/bin/xray-proxy`, `xray-proxy/.config/xray-proxy/shell.bash`,
`tests/test_xray_proxy.py`, `tests/test_xray_proxy_managed.py`.
Команды проверки (обе обязательны):
- `python3 -m unittest discover -s tests` — baseline до изменений: 38
тестов, OK, ~60 с;
- `bash tests/test_proxy_launchers.sh` — baseline: 9 проверок PASS.
После изменений тестов станет меньше (удаляются тесты срезанных фич); все
оставшиеся зелёные, новые счётчики указать в отчёте.
## A. Срез dual-version: остаётся только Xray 26.3.27
- [x] `VERSION_LINE` принимает только `26.3.27`; параметр `version` убран из
сигнатур `parse_vless`, `build_xray_config`, `safe_summary`, `warn_legacy`
и их вызовов; `xray_version` остаётся воротами «поддержанная версия или
отказ» и по-прежнему показывает фактическую версию в сводке.
- [x] Ветка эмиссии `allowInsecure` для 25.12.8 и запрет `pcs`/`vcn` для
25.12.8 удалены. `parse_boolean_fields` (валидация точных `true`/`false`,
требование согласованности обоих полей, предупреждение при истинном
значении) сохраняется; `allowInsecure` никогда не попадает в JSON.
- [x] Тестовая ось версий схлопнута: lifecycle-тест гоняет один прогон
26.3.27 (обе локации профиля — default и `-f` — сохраняются);
`test_old_version_preserves_explicit_legacy_insecure` и все
25.12.8-строки табличных тестов удалены.
## B. Срез проверки владения сокетом через /proc/net/tcp
- [x] `process_owns_listener`, `process_socket_inodes`, `decode_proc_address`
и их вызовы (в `verify_managed_state` и readiness-цикле `command_start`)
удалены. Сохраняются: сверка PID/идентичности процесса, сверка
адрес/порт машинного конфига со state и `check_endpoint`-проверка
доступности порта.
- [x] `test_verified_child_must_own_configured_listener` удалён вместе с
веткой `FAKE_XRAY_LISTEN_PORT` в фейковом xray;
`test_substituted_machine_port_fails_closed...` сохраняется и зелёный.
- [x] Диагностические сообщения, упоминающие проверку владения слушателем
(в `verify_managed_state` и readiness-цикле `command_start`), обновлены:
после среза они описывают фактические проверки (идентичность процесса,
сверка адрес/порт, доступность порта), а не владение сокетом.
## C. Диета входной валидации (файлы пишет сам пользователь)
- [x] `validate_address`: сначала `ipaddress.ip_address`, иначе минимальная
проверка «непустое ASCII-имя без пробелов, схемы `://`, `@`, `/` и
квадратных скобок». RFC-строгость по меткам DNS, лимит 253 и
сопутствующие тесты удалены. Оборачивание IPv6 в `[]` при сборке URL
сохраняется.
- [x] `load_config`: сохраняется отказ на неизвестной секции/ключе и на
отсутствии обязательного ключа (ловит реальные опечатки) и диапазон
порта. Удалены: скан NUL, guard UTF-8-декодирования, отдельный запрет
`[DEFAULT]`, запрет CR/многострочных значений, case-sensitive
`optionxform`; соответствующие подкейсы тестов удалены.
- [x] `read_profile`: сохраняются разрешение симлинка, проверка прав
(group/other → ошибка с подсказкой `chmod 600`) и правило «ровно одна
непустая строка». Удалены скан NUL и guard UTF-8.
- [x] `parse_query`: сохраняются отказ на неизвестном и на повторном
query-параметре. Удалены regex на сырые `%`-последовательности и
посимвольный запрет управляющих символов.
## D. Диета церемоний
- [x] `write_private_json`: простая запись файла с правами 0600 внутри
уже-0700 каталога; O_EXCL/fsync/`os.replace`/PID-суффиксы удалены.
- [x] Валидация `PROXY_PREFLIGHT_TIMEOUT` и `XRAY_PROXY_STOP_TIMEOUT` — один
guard «`float()` успешен и > 0, иначе понятная ошибка»; `math` не
импортируется; тесты экстремальных значений (nan/inf/1e-7/1e308)
удалены. Для `PROXY_PREFLIGHT_TIMEOUT` оставить один тест «плохое
значение → понятный отказ»; для `XRAY_PROXY_STOP_TIMEOUT` отдельного
теста нет и добавлять его не нужно — достаточно guard'а.
- [x] `check_reachable` использует `socket.create_connection(timeout=...)`
без SIGALRM/setitimer-машинерии. Принятый риск (записан здесь):
зависший DNS-резолв на external-пути не ограничен таймаутом — для
личного инструмента пользователь прерывает вручную.
- [x] `extra` и `alpn` разбираются один раз в `parse_vless`, результат
сохраняется в профиле и переиспользуется в `build_xray_config` без
повторного разбора. `parse_json_object` сохраняет оба отказа —
на повторах ключей и на JSON-константах (`reject_constant` остаётся):
существующий тест требует отказ на уровне разбора с диагностикой про
`extra` и без запуска `run -test`, и это дешевле, чем переносить
ответственность на сериализацию.
- [x] `process_alive` — только `os.kill(pid, 0)`; чтение
`/proc/PID/stat` ради zombie-состояния удалено.
- [x] Отпечаток профиля (`profile_fingerprint`) удалён из state и кода:
он не участвует ни в одном решении. Смена профиля по-прежнему
блокируется существованием state. Вместе с ним обновлены `STATE_KEYS` и
все тестовые фикстуры state (включая точную сверку набора ключей в
managed-тестах): устаревший ключ в фикстуре не должен ронять сверку
ключей раньше, чем тест дойдёт до проверяемой идентичности процесса.
## E. shell.bash: средняя форма приёма `env`
- [x] `_proxy_apply_env_file`: при ненулевом коде возврата CLI окружение не
меняется вовсе; успешный вывод разбирается построчно по первому `=`;
применяются (через `printf -v`, без `eval`) только восемь известных
ключей, прочие строки — ошибка без частичного экспорта. Удалены сканы
NUL/CR, отдельный детектор повторов и проверка «ровно восемь строк».
Свойство «нет частичного изменения окружения при ошибке» сохраняется и
остаётся покрыто тестом.
- [x] `tests/test_proxy_launchers.sh` приведён к средней форме: проверки
повторов/CR/NUL/счётчика строк, соответствующие удалённой машинерии,
убраны или переписаны; проверки «ненулевой код возврата CLI → окружение
не тронуто», «неизвестная строка → ошибка без частичного экспорта» и
владения функциями остаются зелёными.
## F. Спека и соседние issue приведены к новому объёму
- [x] Из спеки удалены: ветка 25.12.8 (все места из секции 2 sweep-файла),
флаги `-v`/`--debug` (не реализованы), упоминание отпечатка профиля в
state; описания валидации/атомарности смягчены до фактического
поведения после этого issue. Формулировка идентичности процессов
(«PID + время старта + исполняемый файл») в спеке не меняется.
- [x] Issue 03: добавлен явный мандат «идентичность = PID + время старта
(`/proc/PID/stat`) + исполняемый файл ребёнка / путь скрипта
супервизора; текущий argv-обход заменить этим механизмом»; убраны
унаследованные упоминания срезанных фич, если есть; строка Blocked by
дополнена: «06 — Упрощение».
- [x] Issue 05: приёмка сведена к одной версии Xray 26.3.27; упоминания
25.12.8 удалены. Пункты про РЕД ОС сохранены (решение пользователя
2026-07-20: целевые системы шире Ubuntu — РЕД ОС, возможно Astra Linux
и Alt Linux; инструмент могут использовать коллеги).
- [x] В спеке в раздел ручной проверки добавлены Astra Linux и Alt Linux
как кандидаты «по мере доступности» с тем же правилом, что для РЕД ОС:
недоступная среда явно записывается как неподтверждённая, поддержка не
заявляется без фактической проверки версии Python и прогона.
- [x] В issue 01 и 02 добавлена одна строка после Status: «Часть
требований пересмотрена в 06 — см. 06-simplification-pass.md»; чекбоксы
01 и 02 не редактируются (issue 01 описывает строгую приёмку `env`,
которую этот issue смягчает, — без пометки он противоречил бы коду).
## G. Не трогать (границы)
- Строгий отказ на неизвестном/повторном query-параметре VLESS.
- Точечный `stop` только по проверенному PID; никакого pkill.
- Foreground-супервизор, проброс сигналов, очистка state.
- Изоляция окружения `run` (proxy-переменные только у ребёнка), сохранение
argv и кода возврата, `--`-разделитель.
- Fail-fast `pclaude`/`pcodex`, обратная совместимость: без машинного
конфига — внешний `hproxy.local:12334`.
- Проверка прав файла профиля (chmod 600) и loopback-only вход.
- Авто-диагностика внешнего IP на старте (несмертельная) — остаётся.
- Секреты: полная VLESS-ссылка, UUID, ключи не появляются в stdout/stderr/
argv — все существующие тесты на это остаются зелёными.
- `bash/.bash_functions`, установщик, README — не входят в этот issue.
- Никаких коммитов.
## Отчёт исполнителя
1. Изменённые файлы со строкой «зачем» на каждый.
2. Команда тестов и счётчик (все зелёные, указать новое число).
3. Чекбоксы: закрыт / не закрыт и почему.
4. Итоговая дельта `wc -l` по CLI, shell.bash и обоим тест-файлам
против baseline (1123 / 116 / 467 / 890).
5. Сомнения, если есть.
@@ -0,0 +1,35 @@
# Токен-леджер проекта headless-xray-proxy
Затраты по этапам конвейера, для калибровки эффорта и планирования лимитов
(5-часовые / недельные окна подписок). Прямое сравнение корректно внутри
провайдера; между Claude и Codex метрики разные (у Claude-субагентов
харнесс даёт один суммарный счётчик, у Codex — раздельные input/cached/
output/reasoning), кросс-сравнение — ориентировочное.
## Задача 06 — упрощение (baseline: реализация на HIGH)
| Этап | Агент / модель | Effort | Токены | Прочее |
|---|---|---|---|---|
| KISS-обход спеки и кода | Claude Opus (субагент) | default | 100 025 суммарно | 14 tool uses, 4.5 мин |
| Ревью постановки | Codex gpt-5.6-sol | high | in 1 263 557 (кэш 1 174 784 → свежих ~89k), out 16 862 (reasoning 12 817) | 82 команды |
| Реализация | Codex gpt-5.6-sol | high | in 6 680 053 (кэш 6 492 928 → свежих ~187k), out 29 064 (reasoning 8 280) | 84 команды, −331 строка |
| Саморевью | Codex (resume) | medium | инкремент: свежих in ~155k, out ~4.5k | 8 команд, found 2 fixed 2 |
| Слепое ревью «по задаче» | Claude Opus (субагент) | default | 75 216 суммарно | 26 tool uses, 4.3 мин; 1 NIT |
| Слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 2 336 162 (кэш 2 106 112 → свежих ~230k), out 12 434 (reasoning 7 033) | 48 команд; 1 MAJOR |
| Триаж/починка | Codex (resume) | medium | инкремент: свежих in ~26k, out ~7.3k | 18 команд; FIXED 1, REJECTED 1 |
| Перепроверка CODE-1 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~137k (вкл. оборванный первый заход), out ~1.8k | FIXES_CONFIRMED |
Итог 06 (high-baseline): свежий input ~735k + output ~97k на весь конвейер
(обход, ревью постановки, реализация, саморевью, 2 слепых ревью, триаж,
перепроверка); Claude-доля — 175k суммарно (обход 100k + ревью 75k).
## Задача 03 / 04 (эксперимент: реализация на MEDIUM)
_Заполняется по ходу; сравнивать построчно с 06._
## Наблюдения
- Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим
контекстом дешевле по лимитам, чем кажется по сырому input.
- Сравнивать этапы честнее по «свежий input + output (включая reasoning)»,
а не по валовому input.
@@ -118,11 +118,10 @@ dotfiles `.bash_custom` подключает их двумя независим
`xray-proxy env` сначала выполняет ту же проверку доступности, что и `run`, а `xray-proxy env` сначала выполняет ту же проверку доступности, что и `run`, а
затем печатает восемь строк `KEY=value`: HTTP, HTTPS, ALL и NO_PROXY в верхнем затем печатает восемь строк `KEY=value`: HTTP, HTTPS, ALL и NO_PROXY в верхнем
и нижнем регистре. Переносы строк и NUL в значениях запрещены. `shell.bash` и нижнем регистре. `shell.bash` сначала целиком получает успешный ответ, затем
сначала целиком получает успешный ответ, затем разбирает строки по первому `=`, разбирает строки по первому `=`, принимает только известные ключи и лишь после
проверяет точный белый список ключей и отсутствие повторов, и лишь после этого полной проверки экспортирует полученные значения через `printf -v`. `eval` не
экспортирует значения через `printf -v`. `eval` не используется, частичного используется, частичного изменения окружения при ошибке нет.
изменения окружения при ошибке нет.
У `env` stdout зарезервирован только для восьми машинно-читаемых строк. У `env` stdout зарезервирован только для восьми машинно-читаемых строк.
Предупреждения и ошибки идут в stderr; обычная безопасная сводка этой командой Предупреждения и ошибки идут в stderr; обычная безопасная сводка этой командой
@@ -245,8 +244,7 @@ Xray. Параметры query не зависят от порядка, но н
транспорт, защиту, SNI, ALPN, Host, наличие и длину пути, fingerprint и локальный транспорт, защиту, SNI, ALPN, Host, наличие и длину пути, fingerprint и локальный
порт. XHTTP `path` может содержать tenant ID или bearer-подобный токен, поэтому порт. XHTTP `path` может содержать tenant ID или bearer-подобный токен, поэтому
его содержимое никогда не печатается. Обычный режим не выводит подробный журнал его содержимое никогда не печатается. Обычный режим не выводит подробный журнал
Xray: `-v` добавляет информационные сообщения, а `--debug` включает подробную Xray. UUID, REALITY password/public key, short ID и другие чувствительные
диагностику. UUID, REALITY password/public key, short ID и другие чувствительные
значения обозначаются без фрагментов исходного значения. значения обозначаются без фрагментов исходного значения.
В создаваемом JSON журнал доступа отключён, а уровень ошибок по умолчанию — В создаваемом JSON журнал доступа отключён, а уровень ошибок по умолчанию —
`warning`. `warning`.
@@ -264,14 +262,6 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure=
Соединение после этого может успешно заработать или завершиться ошибкой Соединение после этого может успешно заработать или завершиться ошибкой
сертификата; отдельного раннего отказа нет. сертификата; отдельного раннего отказа нет.
При фактически выбранном Xray 25.12.8 преобразователь сохраняет
`allowInsecure=true` только для согласованного истинного legacy-значения и
предупреждает одновременно об отключённой проверке сертификата и об
использовании старой совместимой версии. Ложное или отсутствующее значение не
включает поле. Автоматический откат с 26.x на 25.12.8 запрещён. Новые
`pcs`/`vcn` в режиме 25.12.8 отклоняются как несовместимые вместо попытки
угадать их старое представление.
### Установка Xray ### Установка Xray
`xray-proxy install` — отдельная явная операция. Она устанавливает Xray в `xray-proxy install` — отдельная явная операция. Она устанавливает Xray в
@@ -294,18 +284,12 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure=
Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и
запускает этим файлом `xray version`. Первая строка должна начинаться с точного запускает этим файлом `xray version`. Первая строка должна начинаться с точного
токена `Xray VERSION`, после полного токена версии допускается только конец токена `Xray VERSION`, после полного токена версии допускается только конец
строки либо ASCII-пробел. Принимаются только версии 25.12.8 и 26.3.27; ведущий строки либо ASCII-пробел. Принимается только версия 26.3.27; ведущий
мусор, похожий более длинный токен, пустой, неверный, завершившийся ошибкой или мусор, похожий более длинный токен, пустой, неверный, завершившийся ошибкой или
содержащий другую версию вывод запрещает запуск. Тот же разрешённый файл содержащий другую версию вывод запрещает запуск. Тот же разрешённый файл
используется для `run -test` и основного процесса. Версия никогда не выводится используется для `run -test` и основного процесса. Версия никогда не выводится
из содержимого VLESS-ссылки. из содержимого VLESS-ссылки.
Xray 25.12.8 — документированный запасной вариант только для старых Hiddify-
профилей, которым действительно требуется `allowInsecure=true`. Это последняя
версия ветки 25.x, где поле применяется без календарного запрета:
[релиз 25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8) и
[реализация TLS 25.12.8](https://github.com/XTLS/Xray-core/blob/v25.12.8/infra/conf/transport_internet.go).
`start` никогда не скачивает бинарник. Если `xray` отсутствует в `PATH`, команда `start` никогда не скачивает бинарник. Если `xray` отсутствует в `PATH`, команда
завершается с подсказкой выполнить `xray-proxy install`. завершается с подсказкой выполнить `xray-proxy install`.
@@ -324,8 +308,8 @@ CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `r
Супервизор владеет блокировкой, дочерним процессом и служебным каталогом Супервизор владеет блокировкой, дочерним процессом и служебным каталогом
`~/.local/state/xray-proxy/`. Файл состояния содержит PID и время старта обоих `~/.local/state/xray-proxy/`. Файл состояния содержит PID и время старта обоих
процессов, путь к бинарнику, порт и безопасный отпечаток профиля, но не VLESS- процессов, путь к бинарнику и порт, но не VLESS-ссылку и не её секреты. `status`
ссылку и не её секреты. `status` и `stop` сверяют PID со временем старта и и `stop` сверяют PID со временем старта и
ожидаемым исполняемым файлом, поэтому повторное использование PID не считается ожидаемым исполняемым файлом, поэтому повторное использование PID не считается
управляемым процессом. `stop` посылает SIGTERM супервизору, а тот завершает Xray управляемым процессом. `stop` посылает SIGTERM супервизору, а тот завершает Xray
и очищает состояние. Если супервизор аварийно исчез, `stop` может завершить и очищает состояние. Если супервизор аварийно исчез, `stop` может завершить
@@ -402,10 +386,9 @@ README репозитория только кратко описывает по
разделяет обязательную проверку процесса и необязательную внешнюю диагностику. разделяет обязательную проверку процесса и необязательную внешнюю диагностику.
- VLESS-ссылка содержит чувствительные данные. CLI сокращает секреты в выводе, - VLESS-ссылка содержит чувствительные данные. CLI сокращает секреты в выводе,
не журналирует их и использует закрытые пользовательские файлы. не журналирует их и использует закрытые пользовательские файлы.
- Устаревшие Hiddify-поля `allowInsecure` и `insecure` неоднозначны. Xray 26.x - Устаревшие Hiddify-поля `allowInsecure` и `insecure` неоднозначны. Они не
игнорирует их на уровне преобразователя и проверяет сертификат; Xray 25.12.8 переносятся в Xray 26.3.27, сертификат проверяется обычным способом, а
сохраняет их только после явного выбора версии. Оба пути сопровождаются истинное значение сопровождается предупреждением.
предупреждением.
- Старые дистрибутивы могут иметь Python ниже 3.8. CLI проверяет версию до - Старые дистрибутивы могут иметь Python ниже 3.8. CLI проверяет версию до
основной работы и сообщает обнаруженную и требуемую версии. основной работы и сообщает обнаруженную и требуемую версии.
- `NO_PROXY` реализуется программами неодинаково. README рекомендует точные IP - `NO_PROXY` реализуется программами неодинаково. README рекомендует точные IP
@@ -423,11 +406,10 @@ README репозитория только кратко описывает по
`headerType=none`, `allowInsecure` и `insecure`. `headerType=none`, `allowInsecure` и `insecure`.
- Неподдерживаемые транспорт, защита, отсутствующие обязательные поля и любой - Неподдерживаемые транспорт, защита, отсутствующие обязательные поля и любой
неизвестный query-параметр дают понятный отказ до запуска Xray. неизвестный query-параметр дают понятный отказ до запуска Xray.
- Сгенерированный JSON проходит `xray run -test` отдельно на Xray 26.3.27 и - Сгенерированный JSON проходит `xray run -test` на Xray 26.3.27 для заявленных
25.12.8 для заявленных совместимыми сочетаний. Интеграционная проверка каждой сочетаний. Интеграционная проверка подтверждает, что один локальный вход
версии подтверждает, что один локальный вход принимает HTTP и SOCKS. принимает HTTP и SOCKS.
- Тесты отдельно проверяют современное удаление устаревших insecure-полей и их - Тесты отдельно проверяют удаление устаревших insecure-полей.
сохранение для явно выбранного Xray 25.12.8.
- Тесты подтверждают сокращение секретов и отсутствие полной VLESS-ссылки в - Тесты подтверждают сокращение секретов и отсутствие полной VLESS-ссылки в
stdout, stderr, служебных файлах и аргументах дочернего процесса. stdout, stderr, служебных файлах и аргументах дочернего процесса.
- Установка проверяется на корректной и повреждённой SHA-256 без записи вне - Установка проверяется на корректной и повреждённой SHA-256 без записи вне
@@ -440,13 +422,14 @@ README репозитория только кратко описывает по
- `run` сохраняет аргументы и код возврата, не оставляет proxy-переменные в - `run` сохраняет аргументы и код возврата, не оставляет proxy-переменные в
родительской Bash-сессии и не запускает команду при недоступном прокси. родительской Bash-сессии и не запускает команду при недоступном прокси.
- `env` не печатает частичный результат при ошибке, а `shell.bash` отклоняет - `env` не печатает частичный результат при ошибке, а `shell.bash` отклоняет
неизвестные и повторные ключи без `eval` и без частичного экспорта. неизвестные строки без `eval` и без частичного экспорта.
- Регрессионные Bash-тесты подтверждают прежнее поведение Hiddify при отсутствии - Регрессионные Bash-тесты подтверждают прежнее поведение Hiddify при отсутствии
машинного конфига, оба регистра proxy-переменных, настраиваемый `NO_PROXY` и машинного конфига, оба регистра proxy-переменных, настраиваемый `NO_PROXY` и
отсутствие автоматического запроса внешнего IP в `proxy_on`/`proxy_off`. отсутствие автоматического запроса внешнего IP в `proxy_on`/`proxy_off`.
- Ручная проверка выполняется минимум на Ubuntu с Python 3.10 и на одной целевой - Ручная проверка выполняется минимум на Ubuntu с Python 3.10 и на одной целевой
удалённой системе; отдельным пунктом проверяется доступная версия Python в удалённой системе. РЕД ОС, Astra Linux и Alt Linux проверяются по мере
РЕД ОС перед заявлением её поддержки. доступности: недоступная среда явно записывается как неподтверждённая, а
поддержка заявляется только после проверки версии Python и полного прогона.
## Влияние на документацию ## Влияние на документацию
@@ -458,8 +441,7 @@ README репозитория только кратко описывает по
- создание машинного INI из шаблона и отдельного файла VLESS-ссылки; - создание машинного INI из шаблона и отдельного файла VLESS-ссылки;
- команды управления, `proxy_on/off`, `pclaude/pcodex`; - команды управления, `proxy_on/off`, `pclaude/pcodex`;
- настройка `NO_PROXY`, ограничения CIDR и диагностика; - настройка `NO_PROXY`, ограничения CIDR и диагностика;
- поддерживаемые семейства VLESS, современную проверку сертификата и явный - поддерживаемые семейства VLESS и современную проверку сертификата;
запасной вариант Xray 25.12.8 для `allowInsecure`;
- отсутствие systemd, автозапуска и автоматического перехода между прокси. - отсутствие systemd, автозапуска и автоматического перехода между прокси.
Корневой README получает только короткий обзор, пример подключения пакета и Корневой README получает только короткий обзор, пример подключения пакета и
+4 -39
View File
@@ -68,7 +68,7 @@ assert_five_proxy_commands() {
assert_eq ready "$output" ".bash_custom не подключил оба независимых модуля" assert_eq ready "$output" ".bash_custom не подключил оба независимых модуля"
) && ok "обычное подключение предоставляет proxy-команды и update_hproxy" ) && ok "обычное подключение предоставляет proxy-команды и update_hproxy"
# proxy_on атомарно применяет ровно восемь переменных и не запрашивает IP. # proxy_on применяет восемь известных переменных и не запрашивает IP.
( (
source "$SHELL_MODULE" source "$SHELL_MODULE"
_proxy_cli() { _proxy_cli() {
@@ -87,16 +87,12 @@ assert_five_proxy_commands() {
assert_eq "$ALL_PROXY" "$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 "proxy.test,localhost,127.0.0.1,::1,[::1],.example.test" "$NO_PROXY" "NO_PROXY"
assert_eq "$NO_PROXY" "$no_proxy" "no_proxy" assert_eq "$NO_PROXY" "$no_proxy" "no_proxy"
) && ok "proxy_on применяет полный восьмиключевой вывод без запроса IP" ) && ok "proxy_on применяет известные ключи без запроса IP"
# Любой неверный машинный вывод оставляет прежнее окружение целиком. # Неизвестная строка в машинном выводе оставляет прежнее окружение целиком.
missing_output="$(valid_env | sed '/^no_proxy=/d')"
unknown_output="$(valid_env; printf 'EXTRA_PROXY=bad\n')" unknown_output="$(valid_env; printf 'EXTRA_PROXY=bad\n')"
duplicate_output="$(valid_env; printf 'HTTP_PROXY=duplicate\n')"
for bad_output in \ for bad_output in \
"$missing_output" \
"$unknown_output" \ "$unknown_output" \
"$duplicate_output" \
"BROKEN-LINE"; do "BROKEN-LINE"; do
( (
source "$SHELL_MODULE" source "$SHELL_MODULE"
@@ -117,7 +113,7 @@ for bad_output in \
assert_eq old-no-lower "$no_proxy" "no_proxy изменился при ошибке" assert_eq old-no-lower "$no_proxy" "no_proxy изменился при ошибке"
) || fail "атомарность нарушена для неверного вывода" ) || fail "атомарность нарушена для неверного вывода"
done done
ok "неверный env-вывод отклоняется без частичного изменения окружения" ok "неизвестная env-строка отклоняется без частичного изменения окружения"
# Даже полный stdout не применяется, если CLI завершился с ошибкой. # Даже полный stdout не применяется, если CLI завершился с ошибкой.
( (
@@ -137,37 +133,6 @@ ok "неверный env-вывод отклоняется без частичн
done done
) && ok "stdout неуспешного env не изменяет окружение" ) && 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 команду, аргументы и код возврата без искажений. # Обёртки передают CLI команду, аргументы и код возврата без искажений.
( (
source "$SHELL_MODULE" source "$SHELL_MODULE"
+32 -62
View File
@@ -4,7 +4,6 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -13,7 +12,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy" CLI = REPO_ROOT / "xray-proxy/.local/bin/xray-proxy"
def checking_socket_setup(outcome="success", close_delay=0.0): def checking_socket_setup(outcome="success"):
if outcome == "success": if outcome == "success":
outcome_line = " return Connection()\n" outcome_line = " return Connection()\n"
elif outcome == "refused": elif outcome == "refused":
@@ -21,13 +20,11 @@ def checking_socket_setup(outcome="success", close_delay=0.0):
else: else:
raise ValueError("unknown socket outcome: {}".format(outcome)) raise ValueError("unknown socket outcome: {}".format(outcome))
return ( return (
"import json, os, socket, time\n" "import json, os, socket\n"
"expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n" "expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n"
"class Connection:\n" "class Connection:\n"
" def close(self):\n" " def close(self):\n"
" time.sleep(" " pass\n"
+ repr(close_delay)
+ ")\n"
"def checked_create_connection(endpoint, timeout):\n" "def checked_create_connection(endpoint, timeout):\n"
" actual_connection = [endpoint[0], endpoint[1], timeout]\n" " actual_connection = [endpoint[0], endpoint[1], timeout]\n"
" if actual_connection != expected_connection:\n" " if actual_connection != expected_connection:\n"
@@ -226,36 +223,27 @@ class XrayProxyCliTest(unittest.TestCase):
self.assertIn("HTTP_PROXY=http://[2001:db8::1]:23456\n", result.stdout) self.assertIn("HTTP_PROXY=http://[2001:db8::1]:23456\n", result.stdout)
self.assertIn("ALL_PROXY=socks5://[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): def test_scoped_ipv6_is_rejected_when_ipaddress_lacks_scope_support(self):
self.write_config(12334, address="slow-dns.example") self.write_config(23456, address="fe80::1%eth0", no_proxy_extra="")
setup = ( setup = (
"import socket, time; " "import ipaddress\n"
"socket.getaddrinfo = lambda *args, **kwargs: time.sleep(5)" "real_ip_address = ipaddress.ip_address\n"
"def python38_ip_address(value):\n"
" if '%' in value:\n"
" raise ValueError('scope IDs unsupported')\n"
" return real_ip_address(value)\n"
"ipaddress.ip_address = python38_ip_address\n"
+ SUCCESSFUL_SOCKET_SETUP
) )
started = time.monotonic()
result = self.run_cli_with_socket_setup( result = self.run_cli_with_socket_setup(
("status",), setup, {"PROXY_PREFLIGHT_TIMEOUT": "0.1"} ("env",),
)
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, setup,
{"PROXY_PREFLIGHT_TIMEOUT": "0.01"}, expected_connection=("fe80::1%eth0", 23456, 2.0),
expected_connection=("127.0.0.1", 23456, 0.01),
) )
self.assertEqual(0, result.returncode, result.stderr) self.assertNotEqual(0, result.returncode)
self.assertIn("доступен", result.stdout) self.assertEqual("", result.stdout)
self.assertNotIn("Traceback", result.stderr) self.assertIn("address", result.stderr)
def test_unreachable_env_prints_no_partial_output(self): def test_unreachable_env_prints_no_partial_output(self):
self.write_config(23456) self.write_config(23456)
@@ -371,30 +359,13 @@ class XrayProxyCliTest(unittest.TestCase):
self.assertNotEqual(0, result.returncode) self.assertNotEqual(0, result.returncode)
self.assertIn("run -- COMMAND", result.stderr) self.assertIn("run -- COMMAND", result.stderr)
def test_invalid_preflight_timeout_is_rejected_by_status_env_and_run(self): def test_invalid_preflight_timeout_is_rejected_clearly(self):
self.write_config(12334) 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( result = self.run_cli(
*command, environment={"PROXY_PREFLIGHT_TIMEOUT": value} "status", environment={"PROXY_PREFLIGHT_TIMEOUT": "not-a-number"}
) )
self.assertNotEqual(0, result.returncode) self.assertNotEqual(0, result.returncode)
self.assertIn("положительным числом", result.stderr) 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): def test_ini_rejects_structure_key_and_value_grammar_errors(self):
valid = ( valid = (
@@ -407,13 +378,9 @@ class XrayProxyCliTest(unittest.TestCase):
) )
invalid_configs = { invalid_configs = {
"unknown section": valid + "[other]\nkey = value\n", "unknown section": valid + "[other]\nkey = value\n",
"default section": "[DEFAULT]\nfoo = bar\n" + valid,
"unknown key": valid + "extra = value\n", "unknown key": valid + "extra = value\n",
"wrong key case": valid.replace("type = external", "Type = external"),
"duplicate key": valid + "port = 12335\n", "duplicate key": valid + "port = 12335\n",
"missing key": valid.replace("name = Test\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 name": valid.replace("name = Test", "name = "),
"empty address": valid.replace("address = 127.0.0.1", "address = "), "empty address": valid.replace("address = 127.0.0.1", "address = "),
"port zero": valid.replace("port = 12334", "port = 0"), "port zero": valid.replace("port = 12334", "port = 0"),
@@ -431,8 +398,17 @@ class XrayProxyCliTest(unittest.TestCase):
self.assertEqual("", result.stdout) self.assertEqual("", result.stdout)
self.assertIn("Ошибка:", result.stderr) self.assertIn("Ошибка:", result.stderr)
def test_address_accepts_only_ip_literals_or_ascii_dns_names(self): def test_address_accepts_ip_literals_and_simple_ascii_names(self):
valid_addresses = ("127.0.0.1", "2001:db8::1", "proxy-1.example") valid_addresses = (
"127.0.0.1",
"2001:db8::1",
"proxy-1.example",
".leading.example",
"trailing.example.",
"bad..example",
"-bad.example",
"bad-.example",
)
for address in valid_addresses: for address in valid_addresses:
with self.subTest(address=address): with self.subTest(address=address):
self.write_config(12334, address=address) self.write_config(12334, address=address)
@@ -441,18 +417,12 @@ class XrayProxyCliTest(unittest.TestCase):
invalid_addresses = ( invalid_addresses = (
"http://proxy.example", "http://proxy.example",
"proxy.example:12334",
"[::1]", "[::1]",
"fe80::1%eth0",
"has space", "has space",
"proxy/path", "proxy/path",
"user@proxy", "user@proxy",
".leading.example",
"trailing.example.",
"bad..example",
"-bad.example",
"bad-.example",
"прокси.example", "прокси.example",
"fe80::1%eth0",
) )
for address in invalid_addresses: for address in invalid_addresses:
with self.subTest(address=address): with self.subTest(address=address):
+35 -126
View File
@@ -6,7 +6,6 @@ import socket
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -85,8 +84,7 @@ record("run", config=config)
inbound = config["inbounds"][0] inbound = config["inbounds"][0]
server = socket.socket() server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 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"], inbound["port"]))
server.bind((inbound["listen"], listen_port))
server.listen(16) server.listen(16)
server.settimeout(0.1) server.settimeout(0.1)
stopping = False stopping = False
@@ -223,15 +221,11 @@ class ManagedXrayCliTest(unittest.TestCase):
"#Synthetic".format(UUID, PBK, SID, extra) "#Synthetic".format(UUID, PBK, SID, extra)
) )
def xhttp_profile(self, extra="", modern=True): def xhttp_profile(self, extra=""):
opaque = quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe="") opaque = quote('{"downloadSettings":{"address":"127.0.0.1"}}', safe="")
modern_fields = ( modern_fields = "&pcs={}%2C{}&vcn=name-a.example%2Cname-b.example".format(
"&pcs={}%2C{}&vcn=name-a.example%2Cname-b.example".format(
PCS_A, PCS_B PCS_A, PCS_B
) )
if modern
else ""
)
return ( return (
"vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none&" "vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none&"
"sni=tls.example&fp=firefox&alpn=http%2F1.1%2Ch2&host=host.example&" "sni=tls.example&fp=firefox&alpn=http%2F1.1%2Ch2&host=host.example&"
@@ -270,8 +264,7 @@ class ManagedXrayCliTest(unittest.TestCase):
return [] return []
return [json.loads(line) for line in self.log.read_text().splitlines()] return [json.loads(line) for line in self.log.read_text().splitlines()]
def start(self, profile=None, version="26.3.27"): def start(self, profile=None):
self.environment["FAKE_XRAY_VERSION"] = "Xray {} (fake)".format(version)
args = [str(CLI), "start"] args = [str(CLI), "start"]
if profile is not None: if profile is not None:
args.extend(["-f", str(profile)]) args.extend(["-f", str(profile)])
@@ -297,12 +290,12 @@ class ManagedXrayCliTest(unittest.TestCase):
for secret in (profile, UUID, PBK, SID, PATH_SECRET): for secret in (profile, UUID, PBK, SID, PATH_SECRET):
self.assertNotIn(secret, text) self.assertNotIn(secret, text)
def test_complete_managed_lifecycle_for_both_versions_and_profile_locations(self): def test_complete_managed_lifecycle_for_both_profile_locations(self):
profiles = (("26.3.27", self.xhttp_profile(), False), ("25.12.8", self.tcp_profile(), True)) profiles = ((self.xhttp_profile(), False), (self.tcp_profile(), True))
for version, profile_text, explicit in profiles: for profile_text, explicit in profiles:
with self.subTest(version=version): with self.subTest(explicit=explicit):
profile = self.write_profile(profile_text, explicit=explicit) profile = self.write_profile(profile_text, explicit=explicit)
process = self.start(profile if explicit else None, version) process = self.start(profile if explicit else None)
status = self.run_cli("status") status = self.run_cli("status")
env = self.run_cli("env") env = self.run_cli("env")
@@ -318,7 +311,9 @@ class ManagedXrayCliTest(unittest.TestCase):
self.assertEqual(0, env.returncode, env.stderr) self.assertEqual(0, env.returncode, env.stderr)
self.assertIn("HTTP_PROXY=http://127.0.0.1:{}".format(self.port), env.stdout) self.assertIn("HTTP_PROXY=http://127.0.0.1:{}".format(self.port), env.stdout)
self.assertEqual(0, child.returncode, child.stderr) self.assertEqual(0, child.returncode, child.stderr)
self.assertIn(str(self.xray.resolve()), process.stdout.readline()) version_summary = process.stdout.readline()
self.assertIn(str(self.xray.resolve()), version_summary)
self.assertIn("26.3.27", version_summary)
with socket.create_connection(("127.0.0.1", self.port), timeout=1) as http: 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") http.sendall(b"GET http://example.test/ HTTP/1.1\r\nHost: example.test\r\n\r\n")
@@ -386,7 +381,17 @@ class ManagedXrayCliTest(unittest.TestCase):
self.assertEqual(str(self.xray.resolve()), state["xray_path"]) self.assertEqual(str(self.xray.resolve()), state["xray_path"])
self.assertEqual("127.0.0.1", state["address"]) self.assertEqual("127.0.0.1", state["address"])
self.assertEqual(self.port, state["port"]) self.assertEqual(self.port, state["port"])
self.assertEqual(64, len(state["profile_fingerprint"])) self.assertEqual(
{
"supervisor_pid",
"xray_pid",
"xray_path",
"config_path",
"address",
"port",
},
set(state),
)
state_text = state_path.read_text(encoding="utf-8") state_text = state_path.read_text(encoding="utf-8")
self.assert_no_secrets(state_text, profile_text) self.assert_no_secrets(state_text, profile_text)
stream = config["outbounds"][0]["streamSettings"] stream = config["outbounds"][0]["streamSettings"]
@@ -410,22 +415,9 @@ class ManagedXrayCliTest(unittest.TestCase):
argv_text = "\0".join(entry["argv"]) argv_text = "\0".join(entry["argv"])
self.assert_no_secrets(argv_text, profile_text) 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): def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self):
invalid_versions = ( invalid_versions = (
"Xray 25.12.8",
"Xray 26.3.270", "Xray 26.3.270",
"junk Xray 26.3.27", "junk Xray 26.3.27",
"Xray 26.5.9", "Xray 26.5.9",
@@ -456,6 +448,7 @@ class ManagedXrayCliTest(unittest.TestCase):
self.tcp_profile("&headerType=http"), self.tcp_profile("&headerType=http"),
self.tcp_profile("&flow=other"), self.tcp_profile("&flow=other"),
self.tcp_profile("&sni=bad%0Aline"), self.tcp_profile("&sni=bad%0Aline"),
self.tcp_profile().replace("sni=front.example", "sni=bad%FF"),
self.tcp_profile().replace("@edge.example:443?", "@edge.example:443/path?"), self.tcp_profile().replace("@edge.example:443?", "@edge.example:443/path?"),
self.tcp_profile().replace("type=tcp", "type=grpc"), self.tcp_profile().replace("type=tcp", "type=grpc"),
self.tcp_profile().replace("&pbk={}".format(PBK), ""), self.tcp_profile().replace("&pbk={}".format(PBK), ""),
@@ -496,22 +489,17 @@ class ManagedXrayCliTest(unittest.TestCase):
self.write_machine_config("xray") self.write_machine_config("xray")
cases = ( cases = (
("26.3.27", "", False, False), ("", False),
("26.3.27", "&allowInsecure=false", False, False), ("&allowInsecure=false", False),
("26.3.27", "&allowInsecure=false&insecure=false", False, False), ("&allowInsecure=false&insecure=false", False),
("26.3.27", "&insecure=true", True, False), ("&insecure=true", True),
("26.3.27", "&allowInsecure=true&insecure=true", True, False), ("&allowInsecure=true&insecure=true", True),
("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: for fields, warns in cases:
with self.subTest(version=version, fields=fields): with self.subTest(fields=fields):
profile_text = self.xhttp_profile(fields, modern=version == "26.3.27") profile_text = self.xhttp_profile(fields)
self.write_profile(profile_text) self.write_profile(profile_text)
process = self.start(version=version) process = self.start()
stdout, stderr = self.stop_and_collect(process) stdout, stderr = self.stop_and_collect(process)
config = next( config = next(
entry["config"] entry["config"]
@@ -519,7 +507,7 @@ class ManagedXrayCliTest(unittest.TestCase):
if entry["kind"] == "test" if entry["kind"] == "test"
) )
tls = config["outbounds"][0]["streamSettings"]["tlsSettings"] tls = config["outbounds"][0]["streamSettings"]["tlsSettings"]
self.assertEqual(emitted, tls.get("allowInsecure", False)) self.assertNotIn("allowInsecure", tls)
self.assertEqual(warns, "Предупреждение" in stderr) self.assertEqual(warns, "Предупреждение" in stderr)
self.assert_no_secrets(stdout + stderr, profile_text) self.assert_no_secrets(stdout + stderr, profile_text)
self.log.unlink() self.log.unlink()
@@ -561,15 +549,6 @@ class ManagedXrayCliTest(unittest.TestCase):
self.assert_no_secrets(result.stdout + result.stderr, profile_text) self.assert_no_secrets(result.stdout + result.stderr, profile_text)
self.log.unlink() 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): def test_fake_run_test_rejects_non_json_constants(self):
for constant in ("NaN", "Infinity", "-Infinity"): for constant in ("NaN", "Infinity", "-Infinity"):
with self.subTest(constant=constant): with self.subTest(constant=constant):
@@ -686,7 +665,6 @@ class ManagedXrayCliTest(unittest.TestCase):
"port": self.port, "port": self.port,
"address": "127.0.0.1", "address": "127.0.0.1",
"config_path": str(state_dir / "client.json"), "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").write_text(json.dumps(state), encoding="utf-8")
(state_dir / "state.json").chmod(0o600) (state_dir / "state.json").chmod(0o600)
@@ -736,75 +714,6 @@ class ManagedXrayCliTest(unittest.TestCase):
pass pass
listener.close() 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): def test_supervisor_forwards_original_signal_to_child(self):
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
with self.subTest(signum=signum): with self.subTest(signum=signum):
+4 -29
View File
@@ -6,24 +6,14 @@ _proxy_cli() {
} }
_proxy_apply_env_file() { _proxy_apply_env_file() {
local output_file="$1" line key value nul_prefix local output_file="$1" line key value
local -A proxy_values=() local -A proxy_values=()
local -A proxy_seen=()
local -a allowed_keys=( local -a allowed_keys=(
HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
http_proxy https_proxy all_proxy no_proxy http_proxy https_proxy all_proxy no_proxy
) )
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 while IFS= read -r line || [ -n "$line" ]; do
if [[ "$line" == *$'\r'* ]]; then
echo "Ошибка: xray-proxy env вернул запрещённый CR" >&2
return 1
fi
if [[ "$line" != *=* ]]; then if [[ "$line" != *=* ]]; then
echo "Ошибка: xray-proxy env вернул строку без '='" >&2 echo "Ошибка: xray-proxy env вернул строку без '='" >&2
return 1 return 1
@@ -37,30 +27,15 @@ _proxy_apply_env_file() {
return 1 return 1
;; ;;
esac 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" proxy_values["$key"]="$value"
done < "$output_file" done < "$output_file"
for key in "${allowed_keys[@]}"; do for key in "${allowed_keys[@]}"; do
if [[ -z "${proxy_seen[$key]+set}" ]]; then if [[ -n "${proxy_values[$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]}" printf -v "$key" '%s' "${proxy_values[$key]}"
export "$key"
fi
done done
export HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
export http_proxy https_proxy all_proxy no_proxy
} }
proxy_on() { proxy_on() {
+50 -197
View File
@@ -11,11 +11,9 @@ if sys.version_info < (3, 8):
import argparse import argparse
import configparser import configparser
import hashlib
import http.client import http.client
import ipaddress import ipaddress
import json import json
import math
import os import os
import re import re
import shutil import shutil
@@ -34,8 +32,7 @@ DEFAULT_NAME = "Hiddify"
DEFAULT_ADDRESS = "hproxy.local" DEFAULT_ADDRESS = "hproxy.local"
DEFAULT_PORT = 12334 DEFAULT_PORT = 12334
DEFAULT_NO_PROXY_EXTRA = (".concept-software.ru",) 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 (26\.3\.27)(?: .*)?$")
VERSION_LINE = re.compile(r"^Xray (25\.12\.8|26\.3\.27)(?: .*)?$")
QUERY_KEYS = { QUERY_KEYS = {
"type", "type",
"security", "security",
@@ -66,7 +63,6 @@ STATE_KEYS = {
"config_path", "config_path",
"address", "address",
"port", "port",
"profile_fingerprint",
} }
@@ -74,10 +70,6 @@ class ProxyError(Exception):
pass pass
class PreflightExpired(Exception):
pass
class ProxyConfig: class ProxyConfig:
def __init__(self, proxy_type, name, address, port, no_proxy_extra): def __init__(self, proxy_type, name, address, port, no_proxy_extra):
self.proxy_type = proxy_type self.proxy_type = proxy_type
@@ -88,11 +80,13 @@ class ProxyConfig:
class VlessProfile: class VlessProfile:
def __init__(self, server, port, profile_id, query): def __init__(self, server, port, profile_id, query, alpn, extra):
self.server = server self.server = server
self.port = port self.port = port
self.profile_id = profile_id self.profile_id = profile_id
self.query = query self.query = query
self.alpn = alpn
self.extra = extra
@property @property
def transport(self): def transport(self):
@@ -133,27 +127,21 @@ def generated_config_path():
def validate_address(value): 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: try:
return ipaddress.ip_address(value) parsed = ipaddress.ip_address(value)
except ValueError: except ValueError:
pass pass
else:
if len(value) > 253: if "%" in value:
raise ProxyError("DNS-имя в поле address слишком длинное") raise ProxyError("поле address не поддерживает IPv6 zone ID")
labels = value.split(".") return parsed
if not labels or any(not DNS_LABEL.fullmatch(label) for label in labels): if (
raise ProxyError( not value
"поле address должно быть адресом без схемы, порта, скобок и URL-разделителей" or not value.isascii()
) or any(character.isspace() for character in value)
or any(separator in value for separator in ("://", "@", "/", "[", "]", "%"))
):
raise ProxyError("поле address должно быть IP-адресом или простым ASCII-именем")
return None return None
@@ -186,15 +174,9 @@ def load_config():
raise ProxyError("машинный конфиг не является обычным файлом: {}".format(path)) raise ProxyError("машинный конфиг не является обычным файлом: {}".format(path))
try: try:
raw = path.read_bytes() text = path.read_text(encoding="utf-8")
except OSError as error: except OSError as error:
raise ProxyError("не удалось прочитать машинный конфиг {}: {}".format(path, 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( parser = configparser.ConfigParser(
interpolation=None, interpolation=None,
@@ -202,14 +184,11 @@ def load_config():
allow_no_value=False, allow_no_value=False,
empty_lines_in_values=False, empty_lines_in_values=False,
) )
parser.optionxform = str
try: try:
parser.read_string(text, source=str(path)) parser.read_string(text, source=str(path))
except configparser.Error as error: except configparser.Error as error:
raise ProxyError("неверный машинный INI: {}".format(error)) raise ProxyError("неверный машинный INI: {}".format(error))
if parser.defaults():
raise ProxyError("машинный INI не допускает секцию [DEFAULT]")
if parser.sections() != ["proxy"]: if parser.sections() != ["proxy"]:
raise ProxyError("машинный INI должен содержать только секцию [proxy]") raise ProxyError("машинный INI должен содержать только секцию [proxy]")
@@ -226,8 +205,6 @@ def load_config():
raise ProxyError("неверные ключи [proxy] ({})".format("; ".join(details))) raise ProxyError("неверные ключи [proxy] ({})".format("; ".join(details)))
values = {key: section.get(key, raw=True) for key in CONFIG_KEYS} 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() proxy_type = values["type"].strip()
if proxy_type not in ("external", "xray"): if proxy_type not in ("external", "xray"):
@@ -255,35 +232,17 @@ def preflight_timeout():
raw = os.environ.get("PROXY_PREFLIGHT_TIMEOUT", "2") raw = os.environ.get("PROXY_PREFLIGHT_TIMEOUT", "2")
try: try:
timeout = float(raw) timeout = float(raw)
if not timeout > 0:
raise ValueError
except ValueError: except ValueError:
raise ProxyError("PROXY_PREFLIGHT_TIMEOUT должен быть положительным числом секунд") raise ProxyError("PROXY_PREFLIGHT_TIMEOUT должен быть положительным числом секунд")
if not math.isfinite(timeout) or timeout <= 0:
raise ProxyError("PROXY_PREFLIGHT_TIMEOUT должен быть положительным числом секунд")
return timeout return timeout
def check_reachable(config, timeout): def check_reachable(config, timeout):
def expire_preflight(_signal_number, _frame):
raise PreflightExpired()
connection = None connection = None
previous_handler = signal.signal(signal.SIGALRM, expire_preflight)
try: try:
signal.setitimer(signal.ITIMER_REAL, timeout)
connection = socket.create_connection((config.address, config.port), timeout=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: except OSError as error:
raise ProxyError( raise ProxyError(
"прокси {} ({}:{}) недоступен: {}".format( "прокси {} ({}:{}) недоступен: {}".format(
@@ -291,8 +250,6 @@ def check_reachable(config, timeout):
) )
) )
finally: finally:
signal.setitimer(signal.ITIMER_REAL, 0)
signal.signal(signal.SIGALRM, previous_handler)
if connection is not None: if connection is not None:
connection.close() connection.close()
@@ -341,14 +298,9 @@ def private_directory(path):
def write_private_json(path, value): def write_private_json(path, value):
private_directory(path.parent) private_directory(path.parent)
temporary = path.with_name(path.name + ".tmp.{}".format(os.getpid()))
descriptor = None
try: try:
descriptor = os.open( descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream: with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = None
json.dump( json.dump(
value, value,
stream, stream,
@@ -357,19 +309,9 @@ def write_private_json(path, value):
allow_nan=False, allow_nan=False,
) )
stream.write("\n") stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(str(temporary), str(path))
path.chmod(0o600) path.chmod(0o600)
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
raise ProxyError("не удалось записать закрытый служебный файл: {}".format(error)) raise ProxyError("не удалось записать закрытый служебный файл: {}".format(error))
finally:
if descriptor is not None:
os.close(descriptor)
try:
temporary.unlink()
except FileNotFoundError:
pass
def read_profile(path): def read_profile(path):
@@ -385,25 +327,16 @@ def read_profile(path):
"конечный файл профиля доступен группе или другим пользователям; выполните chmod 600" "конечный файл профиля доступен группе или другим пользователям; выполните chmod 600"
) )
try: try:
raw = resolved.read_bytes() text = resolved.read_text(encoding="utf-8")
except OSError as error: except OSError as error:
raise ProxyError("не удалось прочитать файл профиля: {}".format(error)) raise ProxyError("не удалось прочитать файл профиля: {}".format(error))
if b"\0" in raw: lines = [line.strip() for line in text.splitlines() if line.strip()]
raise ProxyError("файл профиля содержит запрещённый NUL") if len(lines) != 1:
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-ссылку") raise ProxyError("файл профиля должен содержать ровно одну непустую VLESS-ссылку")
return text return lines[0]
def parse_query(raw_query): def parse_query(raw_query):
if re.search(r"%(?![0-9A-Fa-f]{2})", raw_query):
raise ProxyError("профиль содержит неверное URL-кодирование")
try: try:
pairs = parse_qsl( pairs = parse_qsl(
raw_query, raw_query,
@@ -416,8 +349,6 @@ def parse_query(raw_query):
raise ProxyError("профиль содержит неверную строку параметров") raise ProxyError("профиль содержит неверную строку параметров")
query = {} query = {}
for key, value in pairs: 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: if key in query:
raise ProxyError("профиль содержит повтор query-параметра") raise ProxyError("профиль содержит повтор query-параметра")
if key not in QUERY_KEYS: if key not in QUERY_KEYS:
@@ -471,7 +402,7 @@ def parse_json_object(value):
return parsed return parsed
def parse_vless(url, version): def parse_vless(url):
try: try:
parsed = urlsplit(url) parsed = urlsplit(url)
except ValueError: except ValueError:
@@ -509,6 +440,8 @@ def parse_vless(url, version):
if "core" in query and query["core"] != "xray": if "core" in query and query["core"] != "xray":
raise ProxyError("поддерживается только метаданные core=xray") raise ProxyError("поддерживается только метаданные core=xray")
legacy_insecure = parse_boolean_fields(query) legacy_insecure = parse_boolean_fields(query)
alpn = None
extra = None
if query["type"] == "tcp" and query["security"] == "reality": if query["type"] == "tcp" and query["security"] == "reality":
required(query, "flow", "pbk", "sid") required(query, "flow", "pbk", "sid")
@@ -519,8 +452,6 @@ def parse_vless(url, version):
required(query, "host", "path") required(query, "host", "path")
if "flow" in query or "pbk" in query or "sid" in query: if "flow" in query or "pbk" in query or "sid" in query:
raise ProxyError("параметры профиля несовместимы с XHTTP/TLS") 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: if "pcs" in query:
hashes = list_value(query["pcs"]) hashes = list_value(query["pcs"])
if any(re.fullmatch(r"[0-9A-Fa-f]{64}", item) is None for item in hashes): if any(re.fullmatch(r"[0-9A-Fa-f]{64}", item) is None for item in hashes):
@@ -528,15 +459,15 @@ def parse_vless(url, version):
if "vcn" in query: if "vcn" in query:
list_value(query["vcn"]) list_value(query["vcn"])
if "alpn" in query: if "alpn" in query:
alpn = query["alpn"].split(",") alpn = list_value(query["alpn"])
if not alpn or any(item not in ALPN_VALUES for item in alpn): if not alpn or any(item not in ALPN_VALUES for item in alpn):
raise ProxyError("профиль содержит неподдерживаемый ALPN") raise ProxyError("профиль содержит неподдерживаемый ALPN")
if "extra" in query: if "extra" in query:
parse_json_object(query["extra"]) extra = parse_json_object(query["extra"])
else: else:
raise ProxyError("поддерживаются только TCP/REALITY и XHTTP/TLS") raise ProxyError("поддерживаются только TCP/REALITY и XHTTP/TLS")
profile = VlessProfile(server, port, profile_id, query) profile = VlessProfile(server, port, profile_id, query, alpn, extra)
profile.legacy_insecure = legacy_insecure profile.legacy_insecure = legacy_insecure
return profile return profile
@@ -548,7 +479,7 @@ def list_value(value):
return items return items
def build_xray_config(profile, local_address, local_port, version): def build_xray_config(profile, local_address, local_port):
query = profile.query query = profile.query
settings = { settings = {
"address": profile.server, "address": profile.server,
@@ -574,18 +505,15 @@ def build_xray_config(profile, local_address, local_port, version):
xhttp = {"host": query["host"], "path": query["path"]} xhttp = {"host": query["host"], "path": query["path"]}
if "mode" in query: if "mode" in query:
xhttp["mode"] = query["mode"] xhttp["mode"] = query["mode"]
if "extra" in query: if profile.extra is not None:
xhttp["extra"] = parse_json_object(query["extra"]) xhttp["extra"] = profile.extra
tls = {"serverName": query["sni"], "fingerprint": query["fp"]} tls = {"serverName": query["sni"], "fingerprint": query["fp"]}
if "alpn" in query: if profile.alpn is not None:
tls["alpn"] = list_value(query["alpn"]) tls["alpn"] = profile.alpn
if version == "26.3.27":
if "pcs" in query: if "pcs" in query:
tls["pinnedPeerCertSha256"] = query["pcs"] tls["pinnedPeerCertSha256"] = query["pcs"]
if "vcn" in query: if "vcn" in query:
tls["verifyPeerCertByName"] = query["vcn"] tls["verifyPeerCertByName"] = query["vcn"]
elif profile.legacy_insecure:
tls["allowInsecure"] = True
stream = { stream = {
"network": "xhttp", "network": "xhttp",
"security": "tls", "security": "tls",
@@ -660,12 +588,9 @@ def run_xray_test(path, config_path):
def process_alive(pid): def process_alive(pid):
try: 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) os.kill(pid, 0)
return True return True
except (OSError, ValueError): except OSError:
return False return False
@@ -706,60 +631,6 @@ def process_argument_resolves_to(pid, arguments, expected_path):
return False 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(): def read_managed_state():
path = state_path() path = state_path()
try: try:
@@ -779,7 +650,7 @@ def read_managed_state():
raise ProxyError("состояние управляемого Xray не подтверждено") raise ProxyError("состояние управляемого Xray не подтверждено")
if not all(isinstance(value[key], int) and not isinstance(value[key], bool) for key in ("supervisor_pid", "xray_pid", "port")): if not all(isinstance(value[key], int) and not isinstance(value[key], bool) for key in ("supervisor_pid", "xray_pid", "port")):
raise ProxyError("состояние управляемого Xray не подтверждено") raise ProxyError("состояние управляемого Xray не подтверждено")
if not all(isinstance(value[key], str) for key in ("xray_path", "config_path", "address", "profile_fingerprint")): if not all(isinstance(value[key], str) for key in ("xray_path", "config_path", "address")):
raise ProxyError("состояние управляемого Xray не подтверждено") raise ProxyError("состояние управляемого Xray не подтверждено")
return value return value
@@ -803,11 +674,8 @@ def verify_managed_state(config=None, require_port=True):
or "-c" not in child_args or "-c" not in child_args
or state["config_path"] not in child_args or state["config_path"] not in child_args
or process_parent(child) != supervisor or process_parent(child) != supervisor
or not process_owns_listener(child, state["address"], state["port"])
): ):
raise ProxyError( raise ProxyError("личность процесса управляемого Xray не подтверждена")
"личность процесса или listener управляемого Xray не подтверждены"
)
if config is not None and ( if config is not None and (
config.proxy_type != "xray" config.proxy_type != "xray"
or config.address != state["address"] or config.address != state["address"]
@@ -819,9 +687,8 @@ def verify_managed_state(config=None, require_port=True):
return state return state
def safe_summary(profile, local_port, xray_path, version): def safe_summary(profile, local_port):
query = profile.query query = profile.query
print("Xray: {} ({})".format(xray_path, version), flush=True)
print( print(
"Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format( "Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format(
profile.server, profile.server,
@@ -848,15 +715,11 @@ def safe_summary(profile, local_port, xray_path, version):
print("UUID и ключи: скрыты", flush=True) print("UUID и ключи: скрыты", flush=True)
def warn_legacy(profile, version): def warn_legacy(profile):
if version == "26.3.27" and profile.legacy_insecure: if profile.legacy_insecure:
sys.stderr.write( sys.stderr.write(
"Предупреждение: legacy insecure не переносится; сертификат проверяется обычным способом.\n" "Предупреждение: 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): def diagnose_ip(config):
@@ -967,13 +830,14 @@ def command_start(config, profile_file):
raw_profile = read_profile(profile_path) raw_profile = read_profile(profile_path)
executable = resolve_xray() executable = resolve_xray()
version = xray_version(executable) version = xray_version(executable)
profile = parse_vless(raw_profile, version) profile = parse_vless(raw_profile)
generated = build_xray_config(profile, config.address, config.port, version) generated = build_xray_config(profile, config.address, config.port)
write_private_json(generated_config_path(), generated) write_private_json(generated_config_path(), generated)
try: try:
run_xray_test(executable, generated_config_path()) run_xray_test(executable, generated_config_path())
safe_summary(profile, config.port, executable, version) print("Xray: {} ({})".format(executable, version), flush=True)
warn_legacy(profile, version) safe_summary(profile, config.port)
warn_legacy(profile)
stopping = {"signal": None} stopping = {"signal": None}
def request_stop(signal_number, _frame): def request_stop(signal_number, _frame):
@@ -993,7 +857,6 @@ def command_start(config, profile_file):
"config_path": str(generated_config_path()), "config_path": str(generated_config_path()),
"address": config.address, "address": config.address,
"port": config.port, "port": config.port,
"profile_fingerprint": hashlib.sha256(raw_profile.encode("utf-8")).hexdigest(),
} }
write_private_json(state_path(), state) write_private_json(state_path(), state)
deadline = time.monotonic() + max(1.0, preflight_timeout()) deadline = time.monotonic() + max(1.0, preflight_timeout())
@@ -1003,19 +866,13 @@ def command_start(config, profile_file):
raise ProxyError("Xray завершился до готовности локального порта") raise ProxyError("Xray завершился до готовности локального порта")
try: try:
check_endpoint(config.address, config.port) check_endpoint(config.address, config.port)
if not process_owns_listener(child.pid, config.address, config.port):
raise ProxyError(
"настроенный listener не принадлежит дочернему Xray"
)
last_error = None last_error = None
break break
except ProxyError as error: except ProxyError as error:
last_error = error last_error = error
time.sleep(0.03) time.sleep(0.03)
if last_error is not None: if last_error is not None:
raise ProxyError( raise ProxyError("локальный порт дочернего Xray не стал доступен")
"дочерний Xray не подтвердил владение настроенным listener"
)
diagnose_ip(config) diagnose_ip(config)
while child.poll() is None and stopping["signal"] is None: while child.poll() is None and stopping["signal"] is None:
time.sleep(0.05) time.sleep(0.05)
@@ -1050,10 +907,10 @@ def stop_timeout():
raw = os.environ.get("XRAY_PROXY_STOP_TIMEOUT", "5") raw = os.environ.get("XRAY_PROXY_STOP_TIMEOUT", "5")
try: try:
value = float(raw) value = float(raw)
if not value > 0:
raise ValueError
except ValueError: except ValueError:
raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд") raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд")
if not math.isfinite(value) or value <= 0:
raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд")
return value return value
@@ -1066,11 +923,7 @@ def command_stop():
raise ProxyError("не удалось послать сигнал подтверждённому супервизору") raise ProxyError("не удалось послать сигнал подтверждённому супервизору")
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
if ( if not process_alive(state["xray_pid"]) and not state_path().exists():
not process_alive(state["supervisor_pid"])
and not process_alive(state["xray_pid"])
and not state_path().exists()
):
print("Управляемый Xray остановлен") print("Управляемый Xray остановлен")
return 0 return 0
time.sleep(0.05) time.sleep(0.05)