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:
1 parent
71e16a795a
commit
98bfd6338a
12 files changed
+384
-508
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 получает только короткий обзор, пример подключения пакета и
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+36
-66
@@ -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"):
|
result = self.run_cli(
|
||||||
for command in (("status",), ("env",), ("run", "--", "true")):
|
"status", environment={"PROXY_PREFLIGHT_TIMEOUT": "not-a-number"}
|
||||||
with self.subTest(value=value, command=command):
|
)
|
||||||
result = self.run_cli(
|
self.assertNotEqual(0, result.returncode)
|
||||||
*command, environment={"PROXY_PREFLIGHT_TIMEOUT": value}
|
self.assertIn("положительным числом", result.stderr)
|
||||||
)
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertIn("положительным числом", result.stderr)
|
|
||||||
if command == ("env",):
|
|
||||||
self.assertEqual("", result.stdout)
|
|
||||||
|
|
||||||
def test_extreme_positive_timeouts_fail_without_traceback(self):
|
|
||||||
self.write_config(12334)
|
|
||||||
for value in ("1e-7", "1e308"):
|
|
||||||
with self.subTest(value=value):
|
|
||||||
result = self.run_cli(
|
|
||||||
"status", environment={"PROXY_PREFLIGHT_TIMEOUT": value}
|
|
||||||
)
|
|
||||||
self.assertNotEqual(0, result.returncode)
|
|
||||||
self.assertEqual("", result.stdout)
|
|
||||||
self.assertIn("Ошибка:", result.stderr)
|
|
||||||
self.assertNotIn("Traceback", result.stderr)
|
|
||||||
|
|
||||||
def test_ini_rejects_structure_key_and_value_grammar_errors(self):
|
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):
|
||||||
|
|||||||
@@ -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,14 +221,10 @@ 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&"
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
printf -v "$key" '%s' "${proxy_values[$key]}"
|
||||||
return 1
|
export "$key"
|
||||||
fi
|
fi
|
||||||
done
|
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() {
|
proxy_on() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user