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 index 4aa5018..a6fd35f 100644 --- 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 @@ -9,6 +9,8 @@ Hiddify. Привычные Bash-команды переходят на един **Status:** done +Часть требований пересмотрена в 06 — см. 06-simplification-pass.md. + - [x] CLI работает на Python 3.8+ без сторонних библиотек, ничего не устанавливает и не запускает без явной подкоманды и выдаёт понятную ошибку на более старом Python. 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 index 3aa5534..91b5fb6 100644 --- a/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md +++ b/.scratch/headless-xray-proxy/issues/02-managed-xray-happy-path.md @@ -9,6 +9,8 @@ **Status:** done +Часть требований пересмотрена в 06 — см. 06-simplification-pass.md. + - [x] `start` разрешён только при явном типе `xray`, требует loopback-адрес и согласованный локальный порт, никогда не скачивает бинарник и выбирает первый `xray` из `PATH`. Один разрешённый путь используется для `version`, diff --git a/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md b/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md index f581198..f02ac5f 100644 --- a/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md +++ b/.scratch/headless-xray-proxy/issues/03-safe-process-lifecycle.md @@ -4,16 +4,20 @@ сигналы, устаревшее состояние и аварийное исчезновение супервизора, не затрагивая чужие процессы и не оставляя секретные или временные данные. -**Blocked by:** 02 — Управляемый Xray: полный штатный путь. +**Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение. **Status:** ready-for-agent +**Мандат идентичности:** идентичность = PID + время старта (`/proc/PID/stat`) + +исполняемый файл ребёнка / путь скрипта супервизора; текущий argv-обход заменить +этим механизмом. + - [ ] Супервизор владеет пользовательской блокировкой, допускает только один управляемый экземпляр и не заменяет запущенный профиль другим без явных `stop` и нового `start`. - [ ] Состояние с правами `0600` хранит PID и время старта супервизора и Xray, - путь к бинарнику, порт и безопасный отпечаток профиля, но не ссылку или её - секреты; служебные каталоги имеют права `0700`. + путь к бинарнику и порт, но не ссылку или её секреты; служебные каталоги имеют + права `0700`. - [ ] `status` и `stop` сверяют PID со временем старта и ожидаемым исполняемым файлом, поэтому чужой, устаревший и повторно использованный PID никогда не считается управляемым процессом. 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 index 74f8e4e..8f39488 100644 --- a/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md +++ b/.scratch/headless-xray-proxy/issues/05-package-delivery-and-acceptance.md @@ -19,17 +19,16 @@ - [ ] Основная инструкция объясняет внешний Hiddify и управляемый Xray, подготовку двух файлов настройки, все команды управления, `proxy_on/off`, `pclaude/pcodex`, `NO_PROXY`, ограничения CIDR и диагностику. -- [ ] Инструкция фиксирует две поддержанные семьи VLESS, современную проверку - сертификата, явный запасной Xray 25.12.8 и отсутствие systemd, автозапуска и +- [ ] Инструкция фиксирует две поддержанные семьи VLESS, Xray 26.3.27, + современную проверку сертификата и отсутствие systemd, автозапуска и автоматического перехода между типами прокси. - [ ] Корневой обзор кратко описывает подсистему, показывает подключение пакета и направляет к полной инструкции, не дублируя её. - [ ] Полный автоматический прогон подтверждает Python 3.8-совместимый синтаксис, - внешний Hiddify-сценарий, обе семьи VLESS, обе версии Xray, установку, + внешний Hiddify-сценарий, обе семьи VLESS, Xray 26.3.27, установку, жизненный цикл, Bash-интеграцию и отсутствие утечки секретов. - [ ] На доступной Ubuntu с Python 3.10 проверены установка, `xray run -test` и - работа HTTP и SOCKS через один локальный вход для заявленных совместимых - сочетаний Xray 26.3.27 и 25.12.8. + работа HTTP и SOCKS через один локальный вход для Xray 26.3.27. - [ ] На целевой удалённой системе выполнен тот же пользовательский сценарий; версия Python в РЕД ОС проверена до заявления поддержки, а недоступная среда явно записана как неподтверждённая вместо неподкреплённого обещания. diff --git a/.scratch/headless-xray-proxy/issues/06-simplification-pass.md b/.scratch/headless-xray-proxy/issues/06-simplification-pass.md new file mode 100644 index 0000000..8766a60 --- /dev/null +++ b/.scratch/headless-xray-proxy/issues/06-simplification-pass.md @@ -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. Сомнения, если есть. diff --git a/.scratch/headless-xray-proxy/token-ledger.md b/.scratch/headless-xray-proxy/token-ledger.md new file mode 100644 index 0000000..37258bf --- /dev/null +++ b/.scratch/headless-xray-proxy/token-ledger.md @@ -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. 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 2fee1de..2e3d6b9 100644 --- a/docs/specs/2026-07-15-headless-xray-proxy-design.md +++ b/docs/specs/2026-07-15-headless-xray-proxy-design.md @@ -118,11 +118,10 @@ dotfiles `.bash_custom` подключает их двумя независим `xray-proxy env` сначала выполняет ту же проверку доступности, что и `run`, а затем печатает восемь строк `KEY=value`: HTTP, HTTPS, ALL и NO_PROXY в верхнем -и нижнем регистре. Переносы строк и NUL в значениях запрещены. `shell.bash` -сначала целиком получает успешный ответ, затем разбирает строки по первому `=`, -проверяет точный белый список ключей и отсутствие повторов, и лишь после этого -экспортирует значения через `printf -v`. `eval` не используется, частичного -изменения окружения при ошибке нет. +и нижнем регистре. `shell.bash` сначала целиком получает успешный ответ, затем +разбирает строки по первому `=`, принимает только известные ключи и лишь после +полной проверки экспортирует полученные значения через `printf -v`. `eval` не +используется, частичного изменения окружения при ошибке нет. У `env` stdout зарезервирован только для восьми машинно-читаемых строк. Предупреждения и ошибки идут в stderr; обычная безопасная сводка этой командой @@ -245,8 +244,7 @@ Xray. Параметры query не зависят от порядка, но н транспорт, защиту, SNI, ALPN, Host, наличие и длину пути, fingerprint и локальный порт. XHTTP `path` может содержать tenant ID или bearer-подобный токен, поэтому его содержимое никогда не печатается. Обычный режим не выводит подробный журнал -Xray: `-v` добавляет информационные сообщения, а `--debug` включает подробную -диагностику. UUID, REALITY password/public key, short ID и другие чувствительные +Xray. UUID, REALITY password/public key, short ID и другие чувствительные значения обозначаются без фрагментов исходного значения. В создаваемом JSON журнал доступа отключён, а уровень ошибок по умолчанию — `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-proxy install` — отдельная явная операция. Она устанавливает Xray в @@ -294,18 +284,12 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure= Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и запускает этим файлом `xray version`. Первая строка должна начинаться с точного токена `Xray VERSION`, после полного токена версии допускается только конец -строки либо ASCII-пробел. Принимаются только версии 25.12.8 и 26.3.27; ведущий +строки либо ASCII-пробел. Принимается только версия 26.3.27; ведущий мусор, похожий более длинный токен, пустой, неверный, завершившийся ошибкой или содержащий другую версию вывод запрещает запуск. Тот же разрешённый файл используется для `run -test` и основного процесса. Версия никогда не выводится из содержимого 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`, команда завершается с подсказкой выполнить `xray-proxy install`. @@ -324,8 +308,8 @@ CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `r Супервизор владеет блокировкой, дочерним процессом и служебным каталогом `~/.local/state/xray-proxy/`. Файл состояния содержит PID и время старта обоих -процессов, путь к бинарнику, порт и безопасный отпечаток профиля, но не VLESS- -ссылку и не её секреты. `status` и `stop` сверяют PID со временем старта и +процессов, путь к бинарнику и порт, но не VLESS-ссылку и не её секреты. `status` +и `stop` сверяют PID со временем старта и ожидаемым исполняемым файлом, поэтому повторное использование PID не считается управляемым процессом. `stop` посылает SIGTERM супервизору, а тот завершает Xray и очищает состояние. Если супервизор аварийно исчез, `stop` может завершить @@ -402,10 +386,9 @@ README репозитория только кратко описывает по разделяет обязательную проверку процесса и необязательную внешнюю диагностику. - VLESS-ссылка содержит чувствительные данные. CLI сокращает секреты в выводе, не журналирует их и использует закрытые пользовательские файлы. -- Устаревшие Hiddify-поля `allowInsecure` и `insecure` неоднозначны. Xray 26.x - игнорирует их на уровне преобразователя и проверяет сертификат; Xray 25.12.8 - сохраняет их только после явного выбора версии. Оба пути сопровождаются - предупреждением. +- Устаревшие Hiddify-поля `allowInsecure` и `insecure` неоднозначны. Они не + переносятся в Xray 26.3.27, сертификат проверяется обычным способом, а + истинное значение сопровождается предупреждением. - Старые дистрибутивы могут иметь Python ниже 3.8. CLI проверяет версию до основной работы и сообщает обнаруженную и требуемую версии. - `NO_PROXY` реализуется программами неодинаково. README рекомендует точные IP @@ -423,11 +406,10 @@ README репозитория только кратко описывает по `headerType=none`, `allowInsecure` и `insecure`. - Неподдерживаемые транспорт, защита, отсутствующие обязательные поля и любой неизвестный query-параметр дают понятный отказ до запуска Xray. -- Сгенерированный JSON проходит `xray run -test` отдельно на Xray 26.3.27 и - 25.12.8 для заявленных совместимыми сочетаний. Интеграционная проверка каждой - версии подтверждает, что один локальный вход принимает HTTP и SOCKS. -- Тесты отдельно проверяют современное удаление устаревших insecure-полей и их - сохранение для явно выбранного Xray 25.12.8. +- Сгенерированный JSON проходит `xray run -test` на Xray 26.3.27 для заявленных + сочетаний. Интеграционная проверка подтверждает, что один локальный вход + принимает HTTP и SOCKS. +- Тесты отдельно проверяют удаление устаревших insecure-полей. - Тесты подтверждают сокращение секретов и отсутствие полной VLESS-ссылки в stdout, stderr, служебных файлах и аргументах дочернего процесса. - Установка проверяется на корректной и повреждённой SHA-256 без записи вне @@ -440,13 +422,14 @@ README репозитория только кратко описывает по - `run` сохраняет аргументы и код возврата, не оставляет proxy-переменные в родительской Bash-сессии и не запускает команду при недоступном прокси. - `env` не печатает частичный результат при ошибке, а `shell.bash` отклоняет - неизвестные и повторные ключи без `eval` и без частичного экспорта. + неизвестные строки без `eval` и без частичного экспорта. - Регрессионные Bash-тесты подтверждают прежнее поведение Hiddify при отсутствии машинного конфига, оба регистра proxy-переменных, настраиваемый `NO_PROXY` и отсутствие автоматического запроса внешнего IP в `proxy_on`/`proxy_off`. - Ручная проверка выполняется минимум на Ubuntu с Python 3.10 и на одной целевой - удалённой системе; отдельным пунктом проверяется доступная версия Python в - РЕД ОС перед заявлением её поддержки. + удалённой системе. РЕД ОС, Astra Linux и Alt Linux проверяются по мере + доступности: недоступная среда явно записывается как неподтверждённая, а + поддержка заявляется только после проверки версии Python и полного прогона. ## Влияние на документацию @@ -458,8 +441,7 @@ README репозитория только кратко описывает по - создание машинного INI из шаблона и отдельного файла VLESS-ссылки; - команды управления, `proxy_on/off`, `pclaude/pcodex`; - настройка `NO_PROXY`, ограничения CIDR и диагностика; -- поддерживаемые семейства VLESS, современную проверку сертификата и явный - запасной вариант Xray 25.12.8 для `allowInsecure`; +- поддерживаемые семейства VLESS и современную проверку сертификата; - отсутствие systemd, автозапуска и автоматического перехода между прокси. Корневой README получает только короткий обзор, пример подключения пакета и diff --git a/tests/test_proxy_launchers.sh b/tests/test_proxy_launchers.sh index 722da22..97b1e2d 100755 --- a/tests/test_proxy_launchers.sh +++ b/tests/test_proxy_launchers.sh @@ -68,7 +68,7 @@ assert_five_proxy_commands() { assert_eq ready "$output" ".bash_custom не подключил оба независимых модуля" ) && ok "обычное подключение предоставляет proxy-команды и update_hproxy" -# proxy_on атомарно применяет ровно восемь переменных и не запрашивает IP. +# proxy_on применяет восемь известных переменных и не запрашивает IP. ( source "$SHELL_MODULE" _proxy_cli() { @@ -87,16 +87,12 @@ assert_five_proxy_commands() { 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" +) && 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" @@ -117,7 +113,7 @@ for bad_output in \ assert_eq old-no-lower "$no_proxy" "no_proxy изменился при ошибке" ) || fail "атомарность нарушена для неверного вывода" done -ok "неверный env-вывод отклоняется без частичного изменения окружения" +ok "неизвестная env-строка отклоняется без частичного изменения окружения" # Даже полный stdout не применяется, если CLI завершился с ошибкой. ( @@ -137,37 +133,6 @@ ok "неверный env-вывод отклоняется без частичн 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" diff --git a/tests/test_xray_proxy.py b/tests/test_xray_proxy.py index 22474a1..88840b6 100644 --- a/tests/test_xray_proxy.py +++ b/tests/test_xray_proxy.py @@ -4,7 +4,6 @@ import os import subprocess import sys import tempfile -import time import unittest from pathlib import Path @@ -13,7 +12,7 @@ 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): +def checking_socket_setup(outcome="success"): if outcome == "success": outcome_line = " return Connection()\n" elif outcome == "refused": @@ -21,13 +20,11 @@ def checking_socket_setup(outcome="success", close_delay=0.0): else: raise ValueError("unknown socket outcome: {}".format(outcome)) return ( - "import json, os, socket, time\n" + "import json, os, socket\n" "expected_connection = json.loads(os.environ['TEST_EXPECTED_CONNECTION'])\n" "class Connection:\n" " def close(self):\n" - " time.sleep(" - + repr(close_delay) - + ")\n" + " pass\n" "def checked_create_connection(endpoint, timeout):\n" " actual_connection = [endpoint[0], endpoint[1], timeout]\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("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") + def test_scoped_ipv6_is_rejected_when_ipaddress_lacks_scope_support(self): + self.write_config(23456, address="fe80::1%eth0", no_proxy_extra="") setup = ( - "import socket, time; " - "socket.getaddrinfo = lambda *args, **kwargs: time.sleep(5)" + "import ipaddress\n" + "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( - ("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",), + ("env",), setup, - {"PROXY_PREFLIGHT_TIMEOUT": "0.01"}, - expected_connection=("127.0.0.1", 23456, 0.01), + expected_connection=("fe80::1%eth0", 23456, 2.0), ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertIn("доступен", result.stdout) - self.assertNotIn("Traceback", result.stderr) + self.assertNotEqual(0, result.returncode) + self.assertEqual("", result.stdout) + self.assertIn("address", result.stderr) def test_unreachable_env_prints_no_partial_output(self): self.write_config(23456) @@ -371,30 +359,13 @@ class XrayProxyCliTest(unittest.TestCase): self.assertNotEqual(0, result.returncode) 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) - 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) + result = self.run_cli( + "status", environment={"PROXY_PREFLIGHT_TIMEOUT": "not-a-number"} + ) + self.assertNotEqual(0, result.returncode) + self.assertIn("положительным числом", result.stderr) def test_ini_rejects_structure_key_and_value_grammar_errors(self): valid = ( @@ -407,13 +378,9 @@ class XrayProxyCliTest(unittest.TestCase): ) 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"), @@ -431,8 +398,17 @@ class XrayProxyCliTest(unittest.TestCase): 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") + def test_address_accepts_ip_literals_and_simple_ascii_names(self): + 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: with self.subTest(address=address): self.write_config(12334, address=address) @@ -441,18 +417,12 @@ class XrayProxyCliTest(unittest.TestCase): 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", + "fe80::1%eth0", ) for address in invalid_addresses: with self.subTest(address=address): diff --git a/tests/test_xray_proxy_managed.py b/tests/test_xray_proxy_managed.py index 7522d1e..570253e 100644 --- a/tests/test_xray_proxy_managed.py +++ b/tests/test_xray_proxy_managed.py @@ -6,7 +6,6 @@ import socket import subprocess import sys import tempfile -import threading import time import unittest from pathlib import Path @@ -85,8 +84,7 @@ 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.bind((inbound["listen"], inbound["port"])) server.listen(16) server.settimeout(0.1) stopping = False @@ -223,14 +221,10 @@ class ManagedXrayCliTest(unittest.TestCase): "#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="") - modern_fields = ( - "&pcs={}%2C{}&vcn=name-a.example%2Cname-b.example".format( - PCS_A, PCS_B - ) - if modern - else "" + modern_fields = "&pcs={}%2C{}&vcn=name-a.example%2Cname-b.example".format( + PCS_A, PCS_B ) return ( "vless://{}@cdn.example:8443?type=xhttp&security=tls&encryption=none&" @@ -270,8 +264,7 @@ class ManagedXrayCliTest(unittest.TestCase): 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) + def start(self, profile=None): args = [str(CLI), "start"] if profile is not None: args.extend(["-f", str(profile)]) @@ -297,12 +290,12 @@ class ManagedXrayCliTest(unittest.TestCase): 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): + def test_complete_managed_lifecycle_for_both_profile_locations(self): + profiles = ((self.xhttp_profile(), False), (self.tcp_profile(), True)) + for profile_text, explicit in profiles: + with self.subTest(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") env = self.run_cli("env") @@ -318,7 +311,9 @@ class ManagedXrayCliTest(unittest.TestCase): 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()) + 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: 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("127.0.0.1", state["address"]) 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") self.assert_no_secrets(state_text, profile_text) stream = config["outbounds"][0]["streamSettings"] @@ -410,22 +415,9 @@ class ManagedXrayCliTest(unittest.TestCase): 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 25.12.8", "Xray 26.3.270", "junk Xray 26.3.27", "Xray 26.5.9", @@ -456,6 +448,7 @@ class ManagedXrayCliTest(unittest.TestCase): self.tcp_profile("&headerType=http"), self.tcp_profile("&flow=other"), 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("type=tcp", "type=grpc"), self.tcp_profile().replace("&pbk={}".format(PBK), ""), @@ -496,22 +489,17 @@ class ManagedXrayCliTest(unittest.TestCase): 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), + ("", False), + ("&allowInsecure=false", False), + ("&allowInsecure=false&insecure=false", False), + ("&insecure=true", True), + ("&allowInsecure=true&insecure=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") + for fields, warns in cases: + with self.subTest(fields=fields): + profile_text = self.xhttp_profile(fields) self.write_profile(profile_text) - process = self.start(version=version) + process = self.start() stdout, stderr = self.stop_and_collect(process) config = next( entry["config"] @@ -519,7 +507,7 @@ class ManagedXrayCliTest(unittest.TestCase): if entry["kind"] == "test" ) tls = config["outbounds"][0]["streamSettings"]["tlsSettings"] - self.assertEqual(emitted, tls.get("allowInsecure", False)) + self.assertNotIn("allowInsecure", tls) self.assertEqual(warns, "Предупреждение" in stderr) self.assert_no_secrets(stdout + stderr, profile_text) self.log.unlink() @@ -561,15 +549,6 @@ class ManagedXrayCliTest(unittest.TestCase): 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): @@ -686,7 +665,6 @@ class ManagedXrayCliTest(unittest.TestCase): "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) @@ -736,75 +714,6 @@ class ManagedXrayCliTest(unittest.TestCase): 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): diff --git a/xray-proxy/.config/xray-proxy/shell.bash b/xray-proxy/.config/xray-proxy/shell.bash index c2b2a6d..a88a78d 100644 --- a/xray-proxy/.config/xray-proxy/shell.bash +++ b/xray-proxy/.config/xray-proxy/shell.bash @@ -6,24 +6,14 @@ _proxy_cli() { } _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_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 @@ -37,30 +27,15 @@ _proxy_apply_env_file() { 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 + if [[ -n "${proxy_values[$key]+set}" ]]; then + printf -v "$key" '%s' "${proxy_values[$key]}" + export "$key" 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() { diff --git a/xray-proxy/.local/bin/xray-proxy b/xray-proxy/.local/bin/xray-proxy index ca24a7b..4eee61f 100755 --- a/xray-proxy/.local/bin/xray-proxy +++ b/xray-proxy/.local/bin/xray-proxy @@ -11,11 +11,9 @@ if sys.version_info < (3, 8): import argparse import configparser -import hashlib import http.client import ipaddress import json -import math import os import re import shutil @@ -34,8 +32,7 @@ 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)(?: .*)?$") +VERSION_LINE = re.compile(r"^Xray (26\.3\.27)(?: .*)?$") QUERY_KEYS = { "type", "security", @@ -66,7 +63,6 @@ STATE_KEYS = { "config_path", "address", "port", - "profile_fingerprint", } @@ -74,10 +70,6 @@ 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 @@ -88,11 +80,13 @@ class ProxyConfig: class VlessProfile: - def __init__(self, server, port, profile_id, query): + def __init__(self, server, port, profile_id, query, alpn, extra): self.server = server self.port = port self.profile_id = profile_id self.query = query + self.alpn = alpn + self.extra = extra @property def transport(self): @@ -133,27 +127,21 @@ def generated_config_path(): 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) + parsed = 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-разделителей" - ) + else: + if "%" in value: + raise ProxyError("поле address не поддерживает IPv6 zone ID") + return parsed + if ( + not value + 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 @@ -186,15 +174,9 @@ def load_config(): raise ProxyError("машинный конфиг не является обычным файлом: {}".format(path)) try: - raw = path.read_bytes() + text = path.read_text(encoding="utf-8") 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, @@ -202,14 +184,11 @@ def load_config(): 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]") @@ -226,8 +205,6 @@ def load_config(): 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"): @@ -255,35 +232,17 @@ def preflight_timeout(): raw = os.environ.get("PROXY_PREFLIGHT_TIMEOUT", "2") try: timeout = float(raw) + if not timeout > 0: + raise ValueError 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( @@ -291,8 +250,6 @@ def check_reachable(config, timeout): ) ) finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, previous_handler) if connection is not None: connection.close() @@ -341,14 +298,9 @@ def private_directory(path): 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 - ) + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - descriptor = None json.dump( value, stream, @@ -357,19 +309,9 @@ def write_private_json(path, value): 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): @@ -385,25 +327,16 @@ def read_profile(path): "конечный файл профиля доступен группе или другим пользователям; выполните chmod 600" ) try: - raw = resolved.read_bytes() + text = resolved.read_text(encoding="utf-8") 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(): + lines = [line.strip() for line in text.splitlines() if line.strip()] + if len(lines) != 1: raise ProxyError("файл профиля должен содержать ровно одну непустую VLESS-ссылку") - return text + return lines[0] 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, @@ -416,8 +349,6 @@ def parse_query(raw_query): 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: @@ -471,7 +402,7 @@ def parse_json_object(value): return parsed -def parse_vless(url, version): +def parse_vless(url): try: parsed = urlsplit(url) except ValueError: @@ -509,6 +440,8 @@ def parse_vless(url, version): if "core" in query and query["core"] != "xray": raise ProxyError("поддерживается только метаданные core=xray") legacy_insecure = parse_boolean_fields(query) + alpn = None + extra = None if query["type"] == "tcp" and query["security"] == "reality": required(query, "flow", "pbk", "sid") @@ -519,8 +452,6 @@ def parse_vless(url, version): 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): @@ -528,15 +459,15 @@ def parse_vless(url, version): if "vcn" in query: list_value(query["vcn"]) 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): raise ProxyError("профиль содержит неподдерживаемый ALPN") if "extra" in query: - parse_json_object(query["extra"]) + extra = parse_json_object(query["extra"]) else: 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 return profile @@ -548,7 +479,7 @@ def list_value(value): return items -def build_xray_config(profile, local_address, local_port, version): +def build_xray_config(profile, local_address, local_port): query = profile.query settings = { "address": profile.server, @@ -574,18 +505,15 @@ def build_xray_config(profile, local_address, local_port, version): 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"]) + if profile.extra is not None: + xhttp["extra"] = profile.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 + if profile.alpn is not None: + tls["alpn"] = profile.alpn + if "pcs" in query: + tls["pinnedPeerCertSha256"] = query["pcs"] + if "vcn" in query: + tls["verifyPeerCertByName"] = query["vcn"] stream = { "network": "xhttp", "security": "tls", @@ -660,12 +588,9 @@ def run_xray_test(path, config_path): 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): + except OSError: return False @@ -706,60 +631,6 @@ def process_argument_resolves_to(pid, arguments, expected_path): 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: @@ -779,7 +650,7 @@ def read_managed_state(): 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")): + if not all(isinstance(value[key], str) for key in ("xray_path", "config_path", "address")): raise ProxyError("состояние управляемого Xray не подтверждено") return value @@ -803,11 +674,8 @@ def verify_managed_state(config=None, require_port=True): 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 не подтверждены" - ) + raise ProxyError("личность процесса управляемого Xray не подтверждена") if config is not None and ( config.proxy_type != "xray" or config.address != state["address"] @@ -819,9 +687,8 @@ def verify_managed_state(config=None, require_port=True): return state -def safe_summary(profile, local_port, xray_path, version): +def safe_summary(profile, local_port): query = profile.query - print("Xray: {} ({})".format(xray_path, version), flush=True) print( "Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format( profile.server, @@ -848,15 +715,11 @@ def safe_summary(profile, local_port, xray_path, version): print("UUID и ключи: скрыты", flush=True) -def warn_legacy(profile, version): - if version == "26.3.27" and profile.legacy_insecure: +def warn_legacy(profile): + if 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): @@ -967,13 +830,14 @@ def command_start(config, profile_file): 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) + profile = parse_vless(raw_profile) + generated = build_xray_config(profile, config.address, config.port) 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) + print("Xray: {} ({})".format(executable, version), flush=True) + safe_summary(profile, config.port) + warn_legacy(profile) stopping = {"signal": None} def request_stop(signal_number, _frame): @@ -993,7 +857,6 @@ def command_start(config, profile_file): "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()) @@ -1003,19 +866,13 @@ def command_start(config, profile_file): 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" - ) + raise ProxyError("локальный порт дочернего Xray не стал доступен") diagnose_ip(config) while child.poll() is None and stopping["signal"] is None: time.sleep(0.05) @@ -1050,10 +907,10 @@ def stop_timeout(): raw = os.environ.get("XRAY_PROXY_STOP_TIMEOUT", "5") try: value = float(raw) + if not value > 0: + raise ValueError except ValueError: raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд") - if not math.isfinite(value) or value <= 0: - raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд") return value @@ -1066,11 +923,7 @@ def command_stop(): 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() - ): + if not process_alive(state["xray_pid"]) and not state_path().exists(): print("Управляемый Xray остановлен") return 0 time.sleep(0.05)