feat(xray-proxy): добавлена проверяемая установка Xray командой install
- Зачем:
- пользователь без root должен получать проверенный бинарник Xray 26.3.27
одной явной командой, без риска для живого managed-экземпляра.
- Что:
- команда install [--replace]: пин 26.3.27, маппинг x86_64/aarch64,
скачивание urllib + проверка SHA2-256 из официального четырёхзаписного
.dgst, staging внутри ~/.local/bin и один атомарный os.replace;
- границы размеров (64 MiB архив/бинарник, 4 KiB .dgst, счёт limit+1,
сверка Content-Length) и guard managed-режима через start-lock;
- типизированная StartLockBusyError, offline-тесты реального загрузчика
через подмену urlopen, снапшоты дерева ФС в отказных путях;
- README/спека обновлены, включая справку о последней версии с
allowInsecure (25.12.8, удалена в 26.2.6); тикет 04 закрыт, леджер
дополнен итогами эксперимента.
- Проверка:
- PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests — 79 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
90cc8456cd
commit
0432c4ae8a
7 files changed
+1266
-43
No files matched your search
@@ -1,28 +1,144 @@
|
|||||||
# 04 — Проверяемая пользовательская установка Xray
|
# 04 — Проверяемая пользовательская установка Xray
|
||||||
|
|
||||||
**What to build:** Пользователь без root устанавливает официальный Xray одной
|
**What to build:** Пользователь без root устанавливает официальный Xray
|
||||||
явной командой и получает проверенный бинарник нужной версии без скрытой замены
|
одной явной командой `install` и получает проверенный бинарник
|
||||||
существующей установки или записи за пределами пользовательских каталогов.
|
зафиксированной версии, без скрытой замены существующей установки, без
|
||||||
|
записи за пределами пользовательских каталогов и без риска для живого
|
||||||
|
managed-экземпляра.
|
||||||
|
|
||||||
**Blocked by:** 01 — Внешний прокси и переносимый Bash-интерфейс.
|
**Prerequisites:** 01, 03, 06 — все done. Наследуемые решения: одна
|
||||||
|
поддержанная версия Xray 26.3.27 (06); идентичность managed-процессов по
|
||||||
|
`/proc/PID/exe` и start-lock (03); Python 3.8+ stdlib-only, переносимость
|
||||||
|
Ubuntu / РЕД ОС / Astra / Alt.
|
||||||
|
|
||||||
**Status:** ready-for-agent
|
**Status:** done
|
||||||
|
|
||||||
- [ ] `install` поддерживает Linux x86_64 и aarch64, по умолчанию выбирает
|
## Зафиксированный протокол установки
|
||||||
зафиксированную версию 26.3.27 и принимает другую только через явный параметр
|
|
||||||
версии, не объявляя произвольный релиз поддержанным.
|
- **Только версия 26.3.27** — та же, что принимает `VERSION_LINE`.
|
||||||
- [ ] Архив и файл контрольной суммы скачиваются из официального релиза, после
|
Никакого параметра версии: будущий апгрейд — отдельный тикет, меняющий
|
||||||
чего SHA-256 проверяется до распаковки и установки; несовпадение прекращает
|
пин скачивания, ворота рантайма, фикстуры и документацию вместе.
|
||||||
операцию без изменения установленного бинарника.
|
- **Источник:** `https://github.com/XTLS/Xray-core/releases/download/v26.3.27/`.
|
||||||
- [ ] Работа ведётся во временном пользовательском каталоге, извлечённый бинарник
|
Маппинг архитектур (чистая функция, по `platform.machine()`):
|
||||||
сначала проходит проверку `version`, а затем атомарно устанавливается в
|
`x86_64`/`amd64` → `Xray-linux-64.zip`; `aarch64`/`arm64` →
|
||||||
пользовательский каталог исполняемых файлов.
|
`Xray-linux-arm64-v8a.zip`; прочее — понятный отказ.
|
||||||
- [ ] Существующий бинарник другой версии не перезаписывается без `--replace`;
|
- **Проверка:** скачивается `<архив>.dgst`. Фактический формат официальных
|
||||||
ошибка, отказ или прерывание не оставляют частично установленный файл.
|
файлов — четыре записи `MD5=`, `SHA1=`, `SHA2-256=`, `SHA2-512=`
|
||||||
- [ ] После установки `command -v xray` обязан указывать на установленный файл;
|
(исправлено 2026-07-20 по находке CODE-ревью: однострочного `SHA256=`
|
||||||
иначе команда завершается с понятной подсказкой исправить `PATH`.
|
в релизах нет). Принимается ровно одна запись `SHA2-256= <64 hex в
|
||||||
- [ ] Установщик не использует sudo, systemd или системные каталоги, а команды
|
нижнем регистре>`; дубликаты, uppercase, отсутствие записи или мусор —
|
||||||
`start` и запуск без подкоманды никогда не начинают скачивание.
|
отказ. Хэш архива считается `hashlib.sha256`; несовпадение или
|
||||||
- [ ] Автоматические тесты проверяют обе архитектуры, корректную и повреждённую
|
неожиданный формат — отказ до распаковки, установленный бинарник не
|
||||||
контрольную сумму, существующую версию, `--replace`, атомарность и отсутствие
|
тронут.
|
||||||
записи вне изолированного пользовательского каталога.
|
- **Границы размеров:** скачивание и распаковка ограничены
|
||||||
|
зафиксированными лимитами (архив, `.dgst`, извлекаемый бинарник);
|
||||||
|
фактически прочитанные байты считаются с limit+1; несоответствие
|
||||||
|
объявленному `Content-Length` или превышение лимита — отказ без
|
||||||
|
исчерпания диска/памяти.
|
||||||
|
- **Инструменты:** `urllib.request` для скачивания, `zipfile` для
|
||||||
|
извлечения члена `xray`. Явно исключены: tar, GPG-подписи, curl, wget,
|
||||||
|
пакетные менеджеры, sudo, systemd, системные каталоги.
|
||||||
|
- **Атомарность:** приватный staging-каталог создаётся ВНУТРИ
|
||||||
|
`~/.local/bin` (одна файловая система — `os.replace` без EXDEV);
|
||||||
|
скачивание и распаковка туда; кандидату ставится режим 0755; кандидат
|
||||||
|
проходит проверку `version` (те же ворота 26.3.27); затем один
|
||||||
|
`os.replace` в `~/.local/bin/xray`. До этого вызова существующий файл
|
||||||
|
назначения не изменяется. Staging убирается при любой ошибке и
|
||||||
|
прерывании (best-effort); fsync/power-loss-гарантии не требуются.
|
||||||
|
|
||||||
|
## Чекбоксы
|
||||||
|
|
||||||
|
- [x] Команда `install [--replace]` добавлена в парсер и диспетчеризуется
|
||||||
|
ДО `load_config()`: установка не требует машинного конфига и
|
||||||
|
VLESS-профиля. `start` и запуск без подкоманды по-прежнему никогда не
|
||||||
|
начинают скачивание.
|
||||||
|
- [x] Защита managed-режима: до любого скачивания и изменения назначения
|
||||||
|
`install` берёт существующий start-lock и держит его до конца
|
||||||
|
`os.replace`. Lock занят — немедленный отказ с подсказкой «сначала
|
||||||
|
stop». При взятом lock допускаются только отсутствующий state или
|
||||||
|
доказуемо устаревший (по классификации 03 — stale); живой, сиротский
|
||||||
|
или непроверяемый state — отказ.
|
||||||
|
- [x] Поведение при существующем назначении `~/.local/bin/xray`:
|
||||||
|
- отсутствует → установка;
|
||||||
|
- обычный исполняемый файл, отвечающий `26.3.27` → no-op без сети
|
||||||
|
(успех, сообщение «уже установлено»);
|
||||||
|
- `--replace` → принудительная переустановка с полной проверкой;
|
||||||
|
- иной файл (не отвечает 26.3.27, неисполняемый, битый) или симлинк →
|
||||||
|
отказ без `--replace`; с `--replace` заменяется сам путь, симлинк не
|
||||||
|
разыменовывается;
|
||||||
|
- каталог → всегда понятный отказ.
|
||||||
|
- [x] После установки: проверка `shutil.which("xray")` — резолвится ли в
|
||||||
|
установленный `~/.local/bin/xray`. Нет — ненулевой код с подсказкой
|
||||||
|
добавить `~/.local/bin` в начало `PATH`; готовая установка при этом НЕ
|
||||||
|
откатывается.
|
||||||
|
- [x] Тестовый шов: чистая функция «архитектура → имя ассета» и
|
||||||
|
подменяемый загрузчик (callable), который тесты заменяют in-process.
|
||||||
|
Пользовательского оверрайда базового URL нет.
|
||||||
|
- [x] Автоматические тесты (без root, без сети; временные HOME/PATH/state):
|
||||||
|
- маппинг обеих архитектур (patch значения архитектуры) и отказ на
|
||||||
|
неподдержанной;
|
||||||
|
- корректный `.dgst` → установка; повреждённый хэш и неожиданный формат
|
||||||
|
`.dgst` → отказ до распаковки, назначение не тронуто;
|
||||||
|
- фикстуры: локальный ZIP с фейковым исполняемым, печатающим
|
||||||
|
`Xray 26.3.27 ...`, и парные валидный/битый `.dgst`;
|
||||||
|
- существующая корректная установка → no-op без обращения к загрузчику;
|
||||||
|
`--replace` → переустановка; симлинк/каталог/битый файл — по таблице
|
||||||
|
выше;
|
||||||
|
- атомарность: инъецированная ошибка после скачивания / при распаковке /
|
||||||
|
при проверке `version` → назначение не изменено, staging убран;
|
||||||
|
- запись только в пределах staging/назначения и lock-файла: сверка
|
||||||
|
полного дерева временной ФС до/после;
|
||||||
|
- защита managed: при живом managed-экземпляре (реальная фикстура 03)
|
||||||
|
`install --replace` отказывает с подсказкой «сначала stop», бинарник
|
||||||
|
и state не тронуты;
|
||||||
|
- `shutil.which`-проверка: отсутствие каталога в PATH и конкурирующий
|
||||||
|
более ранний `xray` в PATH.
|
||||||
|
- [x] `xray-proxy/README.md` обновлён: одна поддержанная версия 26.3.27
|
||||||
|
(упоминания 25.12.8 удалены), использование `install [--replace]`,
|
||||||
|
маппинг x86_64/aarch64, SHA-256-проверка, поведение PATH, отказ при
|
||||||
|
живом managed-экземпляре.
|
||||||
|
|
||||||
|
## Сохранить, не трогать
|
||||||
|
|
||||||
|
- Существующее поведение `resolve_xray`, ворот `VERSION_LINE`,
|
||||||
|
managed-режима и всех 49 зелёных тестов + 8 shell-проверок.
|
||||||
|
- Секреты: install не касается профилей и не выводит их; общее правило
|
||||||
|
«VLESS/UUID/ключи никогда в stdout/stderr/argv» остаётся.
|
||||||
|
- `bash/.bash_functions`, установщик dotfiles — вне объёма.
|
||||||
|
- Никаких коммитов.
|
||||||
|
|
||||||
|
## Отчёт исполнителя
|
||||||
|
|
||||||
|
1. Изменённые файлы со строкой «зачем» на каждый.
|
||||||
|
2. Команды тестов и счётчики (baseline: 49 OK / 8 PASS — новых станет
|
||||||
|
больше, указать числа).
|
||||||
|
3. Чекбоксы: закрыт / не закрыт и почему.
|
||||||
|
4. Принятые по ходу решения и сомнения, если есть.
|
||||||
|
|
||||||
|
## Резолюция (2026-07-20)
|
||||||
|
|
||||||
|
Конвейер пройден целиком: постановка → Codex spec-review (high, 5 MAJOR +
|
||||||
|
3 MINOR + 1 NIT, переписана) → реализация (Codex, medium — эксперимент) →
|
||||||
|
саморевью (found 6, fixed 6) → два слепых ревью (CODE: 2 MAJOR + 2 MINOR;
|
||||||
|
TASK: 3 NIT) → триаж (FIXED 7, REJECTED 0) → узкие перепроверки
|
||||||
|
(оба FIXES_CONFIRMED) → приёмка оркестратором.
|
||||||
|
|
||||||
|
Ключевое:
|
||||||
|
|
||||||
|
- Команда `install [--replace]`: пин 26.3.27, маппинг архитектур чистой
|
||||||
|
функцией, `urllib.request` + `zipfile`, staging внутри `~/.local/bin`,
|
||||||
|
один `os.replace`, `version`-ворота кандидата, guard по start-lock.
|
||||||
|
- CODE-1 (MAJOR) оказался дефектом постановки: spec-review зафиксировал
|
||||||
|
несуществующий однострочный формат `SHA256=`; реальные `.dgst` — четыре
|
||||||
|
записи. Протокол в тикете исправлен (см. выше), парсер строгий: ровно
|
||||||
|
одна `SHA2-256= <64 hex в нижнем регистре>`.
|
||||||
|
- Границы размеров: 64 MiB архив/бинарник, 4 KiB `.dgst`, счёт limit+1,
|
||||||
|
сверка Content-Length; offline-покрытие реального `download_file` через
|
||||||
|
подмену `urllib.request.urlopen`.
|
||||||
|
- `StartLockBusyError` вместо парсинга текста ошибки; снапшоты дерева ФС
|
||||||
|
в тестах отказных путей; PATH-тест конкурирующего бинарника.
|
||||||
|
- README дополнён (по просьбе пользователя): последняя версия с
|
||||||
|
`allowInsecure` — 25.12.8, опция удалена в 26.2.6; CLI их не ставит.
|
||||||
|
|
||||||
|
Тесты: 79 OK (baseline 49, +30) + 8 PASS shell-проверок. Затраты — в
|
||||||
|
`.scratch/headless-xray-proxy/token-ledger.md`.
|
||||||
@@ -39,6 +39,15 @@ _Заполняется по ходу; сравнивать построчно
|
|||||||
| 03: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.4k (суммарно 80 560) | 8 tool uses, 1.7 мин; FIXES_CONFIRMED |
|
| 03: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.4k (суммарно 80 560) | 8 tool uses, 1.7 мин; FIXES_CONFIRMED |
|
||||||
| 03: перепроверка CODE-1..5 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~112k, out ~3.2k | 20 команд; FIXES_CONFIRMED |
|
| 03: перепроверка CODE-1..5 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~112k, out ~3.2k | 20 команд; FIXES_CONFIRMED |
|
||||||
|
|
||||||
|
| 04: ревью постановки | Codex gpt-5.6-sol | high | in 2 469 937 (кэш 2 352 896 → свежих ~117k), out 17 051 (reasoning 10 128) | 54 команды; NEEDS_REWORK: 5 MAJOR + 3 MINOR + 1 NIT |
|
||||||
|
| 04: реализация | Codex gpt-5.6-sol | **medium** | in 2 941 682 (кэш 2 828 288 → свежих ~113k), out 18 521 (reasoning 5 278) | 50 команд; +~600 строк; 63 OK (+14) + 8 PASS |
|
||||||
|
| 04: саморевью | Codex (resume) | medium | инкремент: свежих in ~50k, out ~17.2k (reasoning ~9.2k) | 38 команд; found 6 fixed 6; 72 OK + 8 PASS |
|
||||||
|
| 04: слепое ревью «по задаче» | Claude Opus (субагент) | default | 53 463 суммарно | 18 tool uses, 5.7 мин; 3 NIT |
|
||||||
|
| 04: слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 1 774 005 (кэш 1 676 288 → свежих ~98k), out 10 402 (reasoning 5 481) | 46 команд; 2 MAJOR + 2 MINOR (CODE-1 — дефект постановки: реальный формат .dgst) |
|
||||||
|
| 04: триаж/починка | Codex (resume) | medium | инкремент: свежих in ~237k, out ~58.9k (reasoning ~24k) | FIXED 7, REJECTED 0; 79 OK (+30 к baseline) + 8 PASS |
|
||||||
|
| 04: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.7k (суммарно 61 135) | 6 tool uses, 1.7 мин; FIXES_CONFIRMED |
|
||||||
|
| 04: перепроверка CODE-1..4 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~207k, out ~12.5k (reasoning ~6.3k) | FIXES_CONFIRMED |
|
||||||
|
|
||||||
Итог 03 (реализация на MEDIUM): Codex — свежий input ~636k + output ~77k
|
Итог 03 (реализация на MEDIUM): Codex — свежий input ~636k + output ~77k
|
||||||
(ревью постановки 82k/16k, реализация ~248k/29.5k, саморевью 53k/11.6k,
|
(ревью постановки 82k/16k, реализация ~248k/29.5k, саморевью 53k/11.6k,
|
||||||
CODE-ревью 85k/12.8k, триаж 55.5k/16.9k, перепроверка 112k/3.2k);
|
CODE-ревью 85k/12.8k, триаж 55.5k/16.9k, перепроверка 112k/3.2k);
|
||||||
@@ -49,6 +58,25 @@ Claude-доля — 80.5k суммарно (TASK-ревью + перепрове
|
|||||||
|
|
||||||
\* usage первого (убитого) хода не был зафиксирован (нет `turn.completed`); цифры — из resume-хода, по масштабу покрывают весь тред.
|
\* usage первого (убитого) хода не был зафиксирован (нет `turn.completed`); цифры — из resume-хода, по масштабу покрывают весь тред.
|
||||||
|
|
||||||
|
Итог 04 (реализация на MEDIUM): Codex — свежий input ~822k + output ~135k
|
||||||
|
(ревью постановки 117k/17k, реализация 113k/18.5k, саморевью 50k/17.2k,
|
||||||
|
CODE-ревью 98k/10.4k, триаж 237k/58.9k, перепроверка 207k/12.5k);
|
||||||
|
Claude-доля — 61k суммарно (TASK-ревью + перепроверка). Слепые ревью
|
||||||
|
нашли 2 MAJOR + 2 MINOR + 3 NIT, причём CODE-1 (MAJOR) — дефект
|
||||||
|
постановки (spec-review на high выдумал однострочный `SHA256=`), т.е.
|
||||||
|
на счету реализации-medium фактически 1 MAJOR (границы размеров).
|
||||||
|
Примечательно: 04-medium вышла ДОРОЖЕ 06-high по свежим токенам
|
||||||
|
(~822k+135k против ~735k+97k) — экономию реализации съели дорогой триаж
|
||||||
|
(7 находок, включая переделку под исправленный протокол) и перепроверки.
|
||||||
|
|
||||||
|
Сводка эксперимента (свежий input + output Codex; MAJOR от слепых ревью):
|
||||||
|
|
||||||
|
| Задача | Effort реализации | Codex токены | Claude | MAJOR |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 06 | high (baseline) | ~735k + 97k | 175k | 1 |
|
||||||
|
| 03 | medium | ~636k + 77k | 80.5k | 4 (гонки) |
|
||||||
|
| 04 | medium | ~822k + 135k | 61k | 2 (1 — вина постановки) |
|
||||||
|
|
||||||
## Наблюдения
|
## Наблюдения
|
||||||
|
|
||||||
- Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим
|
- Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим
|
||||||
|
|||||||
@@ -266,20 +266,30 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure=
|
|||||||
|
|
||||||
`xray-proxy install` — отдельная явная операция. Она устанавливает Xray в
|
`xray-proxy install` — отдельная явная операция. Она устанавливает Xray в
|
||||||
`~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем
|
`~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем
|
||||||
проверяет SHA-256. Версия по умолчанию зафиксирована в коде клиента на текущем
|
проверяет SHA-256. Поддерживается только зафиксированный релиз 26.3.27;
|
||||||
на момент реализации стабильном релизе 26.3.27. Пользователь может явно указать
|
параметра версии нет. Официальные релизы публикуют необходимые файлы `.dgst`:
|
||||||
другую версию через `--version`; произвольная версия не становится заявленной
|
|
||||||
как поддерживаемая только из-за успешной установки. Официальные релизы
|
|
||||||
публикуют необходимые файлы `.dgst`:
|
|
||||||
[релиз 26.3.27](https://github.com/XTLS/Xray-core/releases/tag/v26.3.27).
|
[релиз 26.3.27](https://github.com/XTLS/Xray-core/releases/tag/v26.3.27).
|
||||||
|
|
||||||
Первая версия установщика поддерживает Linux x86_64 и aarch64. Архив
|
Для Linux `x86_64`/`amd64` используется `Xray-linux-64.zip`, для
|
||||||
распаковывается во временный пользовательский каталог, проверенный бинарник
|
`aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`; остальные архитектуры
|
||||||
сначала запускается с `version`, а затем атомарно переносится в
|
отклоняются. Официальный `.dgst` содержит записи `MD5`, `SHA1`, `SHA2-256` и
|
||||||
`~/.local/bin/xray`. Существующий файл другой версии не перезаписывается без
|
`SHA2-512`; принимается ровно один полный набор со строчными hex-значениями,
|
||||||
явного `--replace`. После установки CLI проверяет, что `command -v xray`
|
для проверки архива используется `SHA2-256`. Дубликаты, uppercase, отсутствие
|
||||||
указывает именно на установленный файл; иначе завершается с подсказкой исправить
|
записи и мусор отклоняются. Архив и извлечённый бинарник ограничены 64 MiB,
|
||||||
`PATH`. `start` всегда показывает фактический путь и версию выбранного бинарника.
|
`.dgst` — 4 KiB; фактический размер считается с limit+1 и должен совпадать с
|
||||||
|
объявленным `Content-Length`. Архив распаковывается в закрытый staging-каталог
|
||||||
|
внутри `~/.local/bin`, проверенный бинарник запускается с `version`, после чего
|
||||||
|
одним `os.replace` атомарно переносится в `~/.local/bin/xray`.
|
||||||
|
|
||||||
|
Корректная существующая версия 26.3.27 даёт no-op без сети. Другой файл или
|
||||||
|
симлинк не заменяется без `--replace`, а каталог не заменяется никогда.
|
||||||
|
Start-lock берётся до проверки назначения и удерживается до завершения
|
||||||
|
`os.replace`; живой, сиротский или неподтверждённый managed-state запрещает
|
||||||
|
установку с подсказкой сначала выполнить `stop`.
|
||||||
|
После установки CLI проверяет, что первый `xray` из `PATH` разрешается в
|
||||||
|
установленный файл; иначе завершается с подсказкой поставить `~/.local/bin` в
|
||||||
|
начало `PATH`, не откатывая готовую установку. `start` всегда показывает
|
||||||
|
фактический путь и версию выбранного бинарника.
|
||||||
|
|
||||||
Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и
|
Перед созданием JSON CLI разрешает первый `xray` из `PATH` до одного пути и
|
||||||
запускает этим файлом `xray version`. Первая строка должна начинаться с точного
|
запускает этим файлом `xray version`. Первая строка должна начинаться с точного
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import fcntl
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import runpy
|
import runpy
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import zipfile
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -14,6 +19,31 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUrlResponse:
|
||||||
|
def __init__(self, data=b"", content_length=None, read_error=None):
|
||||||
|
self.data = data
|
||||||
|
self.position = 0
|
||||||
|
self.read_error = read_error
|
||||||
|
self.headers = {}
|
||||||
|
if content_length is not None:
|
||||||
|
self.headers["Content-Length"] = str(content_length)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, _type, _value, _traceback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self, size=-1):
|
||||||
|
if self.read_error is not None:
|
||||||
|
raise self.read_error
|
||||||
|
if size < 0:
|
||||||
|
size = len(self.data) - self.position
|
||||||
|
chunk = self.data[self.position : self.position + size]
|
||||||
|
self.position += len(chunk)
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
|
||||||
def checking_socket_setup(outcome="success"):
|
def checking_socket_setup(outcome="success"):
|
||||||
if outcome == "success":
|
if outcome == "success":
|
||||||
outcome_line = " return Connection()\n"
|
outcome_line = " return Connection()\n"
|
||||||
@@ -517,5 +547,689 @@ class XrayProxyCliTest(unittest.TestCase):
|
|||||||
self.assertIn("address", result.stderr)
|
self.assertIn("address", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class XrayInstallTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.home = self.root / "home"
|
||||||
|
self.state_home = self.root / "state"
|
||||||
|
self.home.mkdir()
|
||||||
|
self.environment = {
|
||||||
|
"HOME": str(self.home),
|
||||||
|
"XDG_STATE_HOME": str(self.state_home),
|
||||||
|
"PATH": str(self.home / ".local/bin"),
|
||||||
|
}
|
||||||
|
self.module = runpy.run_path(str(CLI))
|
||||||
|
self.destination = self.home / ".local/bin/xray"
|
||||||
|
self.archive = self.root / "fixture.zip"
|
||||||
|
self.write_archive("installed")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temporary.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fake_xray(label="fixture", version="26.3.27"):
|
||||||
|
return (
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
"if [ \"$1\" = version ]; then\n"
|
||||||
|
" printf '%s\\n' 'Xray {} ({})'\n"
|
||||||
|
" exit 0\n"
|
||||||
|
"fi\n"
|
||||||
|
"exit 64\n".format(version, label)
|
||||||
|
).encode("ascii")
|
||||||
|
|
||||||
|
def write_archive(self, label="fixture", version="26.3.27", member="xray"):
|
||||||
|
with zipfile.ZipFile(str(self.archive), "w") as archive:
|
||||||
|
archive.writestr(member, self.fake_xray(label, version))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def official_digest(archive_hash):
|
||||||
|
return (
|
||||||
|
"MD5= {}\n"
|
||||||
|
"SHA1= {}\n"
|
||||||
|
"SHA2-256= {}\n"
|
||||||
|
"SHA2-512= {}\n".format(
|
||||||
|
"1" * 32,
|
||||||
|
"2" * 40,
|
||||||
|
archive_hash,
|
||||||
|
"3" * 128,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def downloader(self, bad_hash=False, digest_text=None, fail_after_archive=False):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def download(url, destination, _max_bytes):
|
||||||
|
calls.append(url)
|
||||||
|
if url.endswith(".dgst"):
|
||||||
|
if fail_after_archive:
|
||||||
|
raise self.module["ProxyError"]("инъецированная ошибка загрузки")
|
||||||
|
archive_hash = hashlib.sha256(self.archive.read_bytes()).hexdigest()
|
||||||
|
if bad_hash:
|
||||||
|
archive_hash = "0" * 64
|
||||||
|
text = digest_text
|
||||||
|
if text is None:
|
||||||
|
text = self.official_digest(archive_hash)
|
||||||
|
destination.write_text(text, encoding="ascii")
|
||||||
|
else:
|
||||||
|
destination.write_bytes(self.archive.read_bytes())
|
||||||
|
|
||||||
|
return download, calls
|
||||||
|
|
||||||
|
def install(self, replace=False, downloader=None, machine="x86_64", path=None):
|
||||||
|
if downloader is None:
|
||||||
|
downloader, _calls = self.downloader()
|
||||||
|
environment = dict(self.environment)
|
||||||
|
if path is not None:
|
||||||
|
environment["PATH"] = path
|
||||||
|
globals_ = self.module["command_install"].__globals__
|
||||||
|
with mock.patch.dict(os.environ, environment, clear=False):
|
||||||
|
with mock.patch.object(globals_["platform"], "machine", return_value=machine):
|
||||||
|
return self.module["command_install"](replace, downloader)
|
||||||
|
|
||||||
|
def write_destination(self, content=None, executable=True):
|
||||||
|
self.destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.destination.write_bytes(content or self.fake_xray("existing"))
|
||||||
|
self.destination.chmod(0o755 if executable else 0o644)
|
||||||
|
|
||||||
|
def tree_snapshot(self):
|
||||||
|
snapshot = {}
|
||||||
|
for path in self.root.rglob("*"):
|
||||||
|
relative = path.relative_to(self.root).as_posix()
|
||||||
|
info = path.lstat()
|
||||||
|
mode = info.st_mode & 0o777
|
||||||
|
if path.is_symlink():
|
||||||
|
snapshot[relative] = ("symlink", mode, os.readlink(str(path)))
|
||||||
|
elif path.is_dir():
|
||||||
|
snapshot[relative] = ("directory", mode)
|
||||||
|
elif path.is_file():
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
snapshot[relative] = ("file", mode, digest)
|
||||||
|
else:
|
||||||
|
snapshot[relative] = ("other", mode)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def tree_snapshot_without_install_lock(self):
|
||||||
|
snapshot = self.tree_snapshot()
|
||||||
|
for path in ("state/xray-proxy/lock", "state/xray-proxy", "state"):
|
||||||
|
snapshot.pop(path, None)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def assert_no_staging(self):
|
||||||
|
if self.destination.parent.exists():
|
||||||
|
self.assertEqual(
|
||||||
|
[], list(self.destination.parent.glob(".xray-install-*"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_install_lock_released(self):
|
||||||
|
lock_path = self.state_home / "xray-proxy/lock"
|
||||||
|
with lock_path.open("r+", encoding="ascii") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
|
||||||
|
def test_architecture_mapping_is_pure_and_rejects_unknown(self):
|
||||||
|
asset_for = self.module["xray_asset_for_architecture"]
|
||||||
|
for machine in ("x86_64", "amd64"):
|
||||||
|
self.assertEqual("Xray-linux-64.zip", asset_for(machine))
|
||||||
|
for machine in ("aarch64", "arm64"):
|
||||||
|
self.assertEqual("Xray-linux-arm64-v8a.zip", asset_for(machine))
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"):
|
||||||
|
asset_for("riscv64")
|
||||||
|
|
||||||
|
def test_valid_digest_installs_verified_executable_and_only_expected_files(self):
|
||||||
|
downloader, calls = self.downloader()
|
||||||
|
self.assertEqual(0, self.install(downloader=downloader))
|
||||||
|
|
||||||
|
self.assertEqual(0o755, self.destination.stat().st_mode & 0o777)
|
||||||
|
self.assertIn(b"installed", self.destination.read_bytes())
|
||||||
|
self.assertEqual(2, len(calls))
|
||||||
|
self.assertTrue(calls[0].endswith("/Xray-linux-64.zip"))
|
||||||
|
self.assertEqual(calls[0] + ".dgst", calls[1])
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"fixture.zip",
|
||||||
|
"home",
|
||||||
|
"home/.local",
|
||||||
|
"home/.local/bin",
|
||||||
|
"home/.local/bin/xray",
|
||||||
|
"state",
|
||||||
|
"state/xray-proxy",
|
||||||
|
"state/xray-proxy/lock",
|
||||||
|
},
|
||||||
|
set(self.tree_snapshot()),
|
||||||
|
)
|
||||||
|
self.assert_no_staging()
|
||||||
|
|
||||||
|
def test_platform_machine_selects_arm64_asset(self):
|
||||||
|
downloader, calls = self.downloader()
|
||||||
|
self.assertEqual(
|
||||||
|
0, self.install(downloader=downloader, machine="aarch64")
|
||||||
|
)
|
||||||
|
self.assertTrue(calls[0].endswith("/Xray-linux-arm64-v8a.zip"))
|
||||||
|
self.assertEqual(calls[0] + ".dgst", calls[1])
|
||||||
|
|
||||||
|
def test_candidate_is_private_while_zip_bytes_are_written(self):
|
||||||
|
os_module = self.module["extract_xray_candidate"].__globals__["os"]
|
||||||
|
real_fdopen = os_module.fdopen
|
||||||
|
observed_modes = []
|
||||||
|
|
||||||
|
def checked_fdopen(descriptor, *arguments, **keywords):
|
||||||
|
descriptor_path = os.readlink("/proc/self/fd/{}".format(descriptor))
|
||||||
|
if Path(descriptor_path).name == "xray":
|
||||||
|
observed_modes.append(os.fstat(descriptor).st_mode & 0o777)
|
||||||
|
return real_fdopen(descriptor, *arguments, **keywords)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
os_module, "fdopen", side_effect=checked_fdopen
|
||||||
|
):
|
||||||
|
self.assertEqual(0, self.install())
|
||||||
|
|
||||||
|
self.assertEqual([0o600], observed_modes)
|
||||||
|
self.assertEqual(0o755, self.destination.stat().st_mode & 0o777)
|
||||||
|
|
||||||
|
def test_bad_hash_and_digest_format_preserve_destination_before_extraction(self):
|
||||||
|
valid_hash = "a" * 64
|
||||||
|
cases = (
|
||||||
|
self.downloader(bad_hash=True)[0],
|
||||||
|
self.downloader(digest_text="SHA256= " + valid_hash + "\n")[0],
|
||||||
|
self.downloader(
|
||||||
|
digest_text=self.official_digest(valid_hash).replace(
|
||||||
|
"SHA2-256= " + valid_hash + "\n", ""
|
||||||
|
)
|
||||||
|
)[0],
|
||||||
|
self.downloader(
|
||||||
|
digest_text=self.official_digest(valid_hash)
|
||||||
|
+ "SHA2-256= "
|
||||||
|
+ valid_hash
|
||||||
|
+ "\n"
|
||||||
|
)[0],
|
||||||
|
self.downloader(
|
||||||
|
digest_text=self.official_digest(valid_hash) + "garbage\n"
|
||||||
|
)[0],
|
||||||
|
self.downloader(
|
||||||
|
digest_text=self.official_digest(valid_hash).replace(
|
||||||
|
valid_hash, "a" * 63
|
||||||
|
)
|
||||||
|
)[0],
|
||||||
|
self.downloader(
|
||||||
|
digest_text=self.official_digest(valid_hash).replace(
|
||||||
|
valid_hash, "a" * 65
|
||||||
|
)
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
for downloader in cases:
|
||||||
|
with self.subTest(downloader=downloader):
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
before = self.tree_snapshot_without_install_lock()
|
||||||
|
with self.assertRaises(self.module["ProxyError"]):
|
||||||
|
self.install(replace=True, downloader=downloader)
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
self.assertEqual(before, self.tree_snapshot_without_install_lock())
|
||||||
|
|
||||||
|
def test_bad_or_truncated_archive_is_rejected_before_zip_open(self):
|
||||||
|
full_archive = self.archive.read_bytes()
|
||||||
|
|
||||||
|
def truncated_downloader(url, destination, _max_bytes):
|
||||||
|
if url.endswith(".dgst"):
|
||||||
|
digest = hashlib.sha256(full_archive).hexdigest()
|
||||||
|
destination.write_text(self.official_digest(digest), encoding="ascii")
|
||||||
|
else:
|
||||||
|
destination.write_bytes(full_archive[: len(full_archive) // 2])
|
||||||
|
|
||||||
|
zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"]
|
||||||
|
with mock.patch.object(
|
||||||
|
zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до SHA-256")
|
||||||
|
) as opened:
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"):
|
||||||
|
self.install(downloader=truncated_downloader)
|
||||||
|
opened.assert_not_called()
|
||||||
|
self.assertFalse(self.destination.exists())
|
||||||
|
self.assert_no_staging()
|
||||||
|
|
||||||
|
def test_real_downloader_installs_complete_declared_responses(self):
|
||||||
|
archive = self.archive.read_bytes()
|
||||||
|
digest = self.official_digest(hashlib.sha256(archive).hexdigest()).encode(
|
||||||
|
"ascii"
|
||||||
|
)
|
||||||
|
responses = [
|
||||||
|
FakeUrlResponse(archive, content_length=len(archive)),
|
||||||
|
FakeUrlResponse(digest, content_length=len(digest)),
|
||||||
|
]
|
||||||
|
urllib_request = self.module["download_file"].__globals__["urllib"].request
|
||||||
|
|
||||||
|
with mock.patch.object(urllib_request, "urlopen", side_effect=responses):
|
||||||
|
self.assertEqual(
|
||||||
|
0, self.install(downloader=self.module["download_file"])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_real_downloader_refuses_length_errors_and_releases_install(self):
|
||||||
|
archive = self.archive.read_bytes()
|
||||||
|
cases = (
|
||||||
|
(
|
||||||
|
"early EOF",
|
||||||
|
FakeUrlResponse(archive, content_length=len(archive) + 1),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"extra bytes",
|
||||||
|
FakeUrlResponse(archive, content_length=len(archive) - 1),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"declared oversized",
|
||||||
|
FakeUrlResponse(
|
||||||
|
b"",
|
||||||
|
content_length=self.module["MAX_ARCHIVE_BYTES"] + 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("url error", urllib.error.URLError("offline")),
|
||||||
|
(
|
||||||
|
"http error",
|
||||||
|
FakeUrlResponse(read_error=http.client.HTTPException("broken HTTP")),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"incomplete read",
|
||||||
|
FakeUrlResponse(
|
||||||
|
read_error=http.client.IncompleteRead(b"partial", 10)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
urllib_request = self.module["download_file"].__globals__["urllib"].request
|
||||||
|
|
||||||
|
for label, outcome in cases:
|
||||||
|
with self.subTest(label=label):
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
before = self.tree_snapshot_without_install_lock()
|
||||||
|
with mock.patch.object(urllib_request, "urlopen", side_effect=[outcome]):
|
||||||
|
with self.assertRaises(self.module["ProxyError"]):
|
||||||
|
self.install(
|
||||||
|
replace=True, downloader=self.module["download_file"]
|
||||||
|
)
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
self.assert_install_lock_released()
|
||||||
|
self.assertEqual(before, self.tree_snapshot_without_install_lock())
|
||||||
|
|
||||||
|
def test_real_downloader_counts_limit_plus_one_without_content_length(self):
|
||||||
|
urllib_request = self.module["download_file"].__globals__["urllib"].request
|
||||||
|
globals_ = self.module["command_install"].__globals__
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
|
||||||
|
with mock.patch.dict(globals_, {"MAX_ARCHIVE_BYTES": 8}):
|
||||||
|
response = FakeUrlResponse(b"123456789")
|
||||||
|
with mock.patch.object(urllib_request, "urlopen", return_value=response):
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "размер"):
|
||||||
|
self.install(
|
||||||
|
replace=True, downloader=self.module["download_file"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
self.assert_install_lock_released()
|
||||||
|
|
||||||
|
def test_uppercase_digest_is_rejected_before_zip_open(self):
|
||||||
|
digest = hashlib.sha256(self.archive.read_bytes()).hexdigest().upper()
|
||||||
|
downloader, _calls = self.downloader(
|
||||||
|
digest_text=self.official_digest(digest)
|
||||||
|
)
|
||||||
|
zip_file = self.module["extract_xray_candidate"].__globals__["zipfile"]
|
||||||
|
with mock.patch.object(
|
||||||
|
zip_file, "ZipFile", side_effect=AssertionError("ZIP открыт до digest")
|
||||||
|
) as opened:
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "SHA-256"):
|
||||||
|
self.install(downloader=downloader)
|
||||||
|
opened.assert_not_called()
|
||||||
|
self.assertFalse(self.destination.exists())
|
||||||
|
self.assert_no_staging()
|
||||||
|
|
||||||
|
def test_existing_verified_install_is_noop_without_downloader(self):
|
||||||
|
self.write_destination()
|
||||||
|
downloader = mock.Mock(side_effect=AssertionError("network used"))
|
||||||
|
self.assertEqual(0, self.install(downloader=downloader))
|
||||||
|
downloader.assert_not_called()
|
||||||
|
|
||||||
|
def test_existing_version_gate_keeps_timeout_and_avoids_downloader(self):
|
||||||
|
self.write_destination()
|
||||||
|
downloader = mock.Mock(side_effect=AssertionError("network used"))
|
||||||
|
subprocess_module = self.module["xray_version"].__globals__["subprocess"]
|
||||||
|
timeout = subprocess.TimeoutExpired([str(self.destination), "version"], 5)
|
||||||
|
|
||||||
|
with mock.patch.object(subprocess_module, "run", side_effect=timeout) as run:
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "--replace"):
|
||||||
|
self.install(downloader=downloader)
|
||||||
|
|
||||||
|
downloader.assert_not_called()
|
||||||
|
self.assertEqual(5, run.call_args.kwargs["timeout"])
|
||||||
|
self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes())
|
||||||
|
|
||||||
|
def test_replace_reinstalls_verified_existing_file(self):
|
||||||
|
self.write_destination(self.fake_xray("old"))
|
||||||
|
downloader, calls = self.downloader()
|
||||||
|
self.assertEqual(0, self.install(replace=True, downloader=downloader))
|
||||||
|
self.assertIn(b"installed", self.destination.read_bytes())
|
||||||
|
self.assertEqual(2, len(calls))
|
||||||
|
|
||||||
|
def test_destination_table_for_symlink_directory_and_bad_files(self):
|
||||||
|
self.destination.parent.mkdir(parents=True)
|
||||||
|
target = self.root / "target"
|
||||||
|
target.write_bytes(b"target sentinel")
|
||||||
|
self.destination.symlink_to(target)
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "--replace"):
|
||||||
|
self.install()
|
||||||
|
self.assertTrue(self.destination.is_symlink())
|
||||||
|
self.assertEqual(0, self.install(replace=True))
|
||||||
|
self.assertFalse(self.destination.is_symlink())
|
||||||
|
self.assertEqual(b"target sentinel", target.read_bytes())
|
||||||
|
|
||||||
|
self.destination.unlink()
|
||||||
|
self.destination.mkdir()
|
||||||
|
for replace in (False, True):
|
||||||
|
with self.subTest(directory=True, replace=replace):
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "каталог"):
|
||||||
|
self.install(replace=replace)
|
||||||
|
self.destination.rmdir()
|
||||||
|
|
||||||
|
for content, executable in ((b"broken", True), (self.fake_xray(), False)):
|
||||||
|
with self.subTest(content=content, executable=executable):
|
||||||
|
self.write_destination(content, executable)
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "--replace"):
|
||||||
|
self.install()
|
||||||
|
self.assertEqual(0, self.install(replace=True))
|
||||||
|
self.assertIn(b"installed", self.destination.read_bytes())
|
||||||
|
self.destination.unlink()
|
||||||
|
|
||||||
|
def test_parent_symlink_cannot_redirect_install_outside_home(self):
|
||||||
|
outside = self.root / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
(self.home / ".local").symlink_to(outside, target_is_directory=True)
|
||||||
|
downloader = mock.Mock()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "домашнего каталога"):
|
||||||
|
self.install(downloader=downloader)
|
||||||
|
|
||||||
|
downloader.assert_not_called()
|
||||||
|
self.assertEqual([], list(outside.iterdir()))
|
||||||
|
|
||||||
|
def test_managed_state_guard_only_allows_absent_or_stale(self):
|
||||||
|
guard = self.module["require_install_safe_managed_state"]
|
||||||
|
globals_ = guard.__globals__
|
||||||
|
present = mock.Mock()
|
||||||
|
present.lstat.return_value = mock.Mock()
|
||||||
|
with mock.patch.dict(
|
||||||
|
globals_,
|
||||||
|
{
|
||||||
|
"state_path": mock.Mock(return_value=present),
|
||||||
|
"read_managed_state": mock.Mock(return_value={"state": "fixture"}),
|
||||||
|
},
|
||||||
|
):
|
||||||
|
for outcome in ("healthy", "orphan", "unverifiable"):
|
||||||
|
with self.subTest(outcome=outcome):
|
||||||
|
globals_["managed_state_outcome"] = mock.Mock(return_value=outcome)
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "stop"):
|
||||||
|
guard()
|
||||||
|
globals_["managed_state_outcome"] = mock.Mock(return_value="stale")
|
||||||
|
guard()
|
||||||
|
|
||||||
|
absent = mock.Mock()
|
||||||
|
absent.lstat.side_effect = FileNotFoundError()
|
||||||
|
with mock.patch.dict(
|
||||||
|
globals_, {"state_path": mock.Mock(return_value=absent)}
|
||||||
|
):
|
||||||
|
guard()
|
||||||
|
|
||||||
|
def test_command_classifies_managed_state_before_even_noop_destination(self):
|
||||||
|
self.write_destination()
|
||||||
|
downloader = mock.Mock(side_effect=AssertionError("network used"))
|
||||||
|
guard = mock.Mock(
|
||||||
|
side_effect=self.module["ProxyError"](
|
||||||
|
"managed Xray работает; сначала выполните xray-proxy stop"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
globals_ = self.module["command_install"].__globals__
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
globals_, {"require_install_safe_managed_state": guard}
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "stop"):
|
||||||
|
self.install(downloader=downloader)
|
||||||
|
|
||||||
|
guard.assert_called_once_with()
|
||||||
|
downloader.assert_not_called()
|
||||||
|
self.assertEqual(self.fake_xray("existing"), self.destination.read_bytes())
|
||||||
|
|
||||||
|
def test_download_extraction_and_version_errors_are_atomic_and_clean_staging(self):
|
||||||
|
failures = []
|
||||||
|
failures.append(self.downloader(fail_after_archive=True)[0])
|
||||||
|
missing_member = self.root / "missing-member.zip"
|
||||||
|
with zipfile.ZipFile(str(missing_member), "w") as archive:
|
||||||
|
archive.writestr("not-xray", b"data")
|
||||||
|
|
||||||
|
def missing_member_downloader(url, destination, _max_bytes):
|
||||||
|
source = missing_member if not url.endswith(".dgst") else None
|
||||||
|
if source is not None:
|
||||||
|
destination.write_bytes(source.read_bytes())
|
||||||
|
else:
|
||||||
|
digest = hashlib.sha256(missing_member.read_bytes()).hexdigest()
|
||||||
|
destination.write_text(self.official_digest(digest), encoding="ascii")
|
||||||
|
|
||||||
|
failures.append(missing_member_downloader)
|
||||||
|
self.write_archive("wrong", version="26.3.26")
|
||||||
|
failures.append(self.downloader()[0])
|
||||||
|
|
||||||
|
for downloader in failures:
|
||||||
|
with self.subTest(downloader=downloader):
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
before = self.tree_snapshot_without_install_lock()
|
||||||
|
with self.assertRaises(self.module["ProxyError"]):
|
||||||
|
self.install(replace=True, downloader=downloader)
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
self.assertEqual(before, self.tree_snapshot_without_install_lock())
|
||||||
|
|
||||||
|
def test_corrupt_zip_with_matching_digest_is_atomic_and_cleaned(self):
|
||||||
|
corrupt = b"not a ZIP archive"
|
||||||
|
|
||||||
|
def corrupt_downloader(url, destination, _max_bytes):
|
||||||
|
if url.endswith(".dgst"):
|
||||||
|
digest = hashlib.sha256(corrupt).hexdigest()
|
||||||
|
destination.write_text(self.official_digest(digest), encoding="ascii")
|
||||||
|
else:
|
||||||
|
destination.write_bytes(corrupt)
|
||||||
|
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
before = self.tree_snapshot_without_install_lock()
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "извлечь"):
|
||||||
|
self.install(replace=True, downloader=corrupt_downloader)
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
self.assertEqual(before, self.tree_snapshot_without_install_lock())
|
||||||
|
|
||||||
|
def test_injected_oversized_archive_and_digest_are_bounded(self):
|
||||||
|
globals_ = self.module["command_install"].__globals__
|
||||||
|
cases = (
|
||||||
|
("MAX_ARCHIVE_BYTES", len(self.archive.read_bytes()) - 1, self.downloader()[0]),
|
||||||
|
(
|
||||||
|
"MAX_DIGEST_BYTES",
|
||||||
|
8,
|
||||||
|
self.downloader(digest_text=self.official_digest("a" * 64))[0],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for constant, limit, downloader in cases:
|
||||||
|
with self.subTest(constant=constant):
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
with mock.patch.dict(globals_, {constant: limit}):
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "размер"):
|
||||||
|
self.install(replace=True, downloader=downloader)
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
|
||||||
|
def test_zip_member_declared_over_limit_is_rejected_before_extraction(self):
|
||||||
|
globals_ = self.module["command_install"].__globals__
|
||||||
|
binary_size = len(self.fake_xray())
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
|
||||||
|
with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": binary_size - 1}):
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "размер"):
|
||||||
|
self.install(replace=True)
|
||||||
|
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
|
||||||
|
def test_streamed_zip_output_cannot_exceed_limit_even_if_metadata_lies(self):
|
||||||
|
class FakeInfo:
|
||||||
|
filename = "xray"
|
||||||
|
file_size = 8
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_dir():
|
||||||
|
return False
|
||||||
|
|
||||||
|
class FakeArchive:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, _type, _value, _traceback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def infolist():
|
||||||
|
return [FakeInfo()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def open(_member):
|
||||||
|
return FakeUrlResponse(b"123456789")
|
||||||
|
|
||||||
|
globals_ = self.module["command_install"].__globals__
|
||||||
|
zip_file = globals_["zipfile"]
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
with mock.patch.dict(globals_, {"MAX_XRAY_BYTES": 8}):
|
||||||
|
with mock.patch.object(zip_file, "ZipFile", return_value=FakeArchive()):
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "размер"):
|
||||||
|
self.install(replace=True)
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
|
||||||
|
def test_interruption_preserves_destination_cleans_staging_and_releases_lock(self):
|
||||||
|
sentinel = b"destination sentinel"
|
||||||
|
self.write_destination(sentinel)
|
||||||
|
before = self.tree_snapshot_without_install_lock()
|
||||||
|
|
||||||
|
def interrupted_downloader(_url, destination, _max_bytes):
|
||||||
|
destination.write_bytes(b"partial")
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
with self.assertRaises(KeyboardInterrupt):
|
||||||
|
self.install(replace=True, downloader=interrupted_downloader)
|
||||||
|
|
||||||
|
self.assertEqual(sentinel, self.destination.read_bytes())
|
||||||
|
self.assert_no_staging()
|
||||||
|
lock_path = self.state_home / "xray-proxy/lock"
|
||||||
|
with lock_path.open("r+", encoding="ascii") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
self.assertEqual(before, self.tree_snapshot_without_install_lock())
|
||||||
|
|
||||||
|
def test_start_lock_is_held_during_download_and_through_replace(self):
|
||||||
|
downloader, _calls = self.downloader()
|
||||||
|
lock_path = self.state_home / "xray-proxy/lock"
|
||||||
|
|
||||||
|
def assert_lock_held():
|
||||||
|
with lock_path.open("r+", encoding="ascii") as competing:
|
||||||
|
with self.assertRaises(BlockingIOError):
|
||||||
|
fcntl.flock(competing, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
|
||||||
|
def checked_downloader(url, destination, max_bytes):
|
||||||
|
assert_lock_held()
|
||||||
|
downloader(url, destination, max_bytes)
|
||||||
|
|
||||||
|
os_module = self.module["command_install"].__globals__["os"]
|
||||||
|
real_replace = os_module.replace
|
||||||
|
|
||||||
|
def checked_replace(source, destination):
|
||||||
|
real_replace(source, destination)
|
||||||
|
assert_lock_held()
|
||||||
|
|
||||||
|
with mock.patch.object(os_module, "replace", side_effect=checked_replace):
|
||||||
|
self.assertEqual(0, self.install(downloader=checked_downloader))
|
||||||
|
|
||||||
|
with lock_path.open("r+", encoding="ascii") as released:
|
||||||
|
fcntl.flock(released, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
|
||||||
|
def test_unsupported_architecture_refuses_before_downloading(self):
|
||||||
|
downloader = mock.Mock()
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "не поддерживается"):
|
||||||
|
self.install(downloader=downloader, machine="riscv64")
|
||||||
|
downloader.assert_not_called()
|
||||||
|
self.assertFalse(self.destination.exists())
|
||||||
|
|
||||||
|
def test_path_missing_or_competing_returns_error_without_rollback(self):
|
||||||
|
cases = (("empty", False), ("competitor", True))
|
||||||
|
for directory_name, competing in cases:
|
||||||
|
with self.subTest(directory_name=directory_name):
|
||||||
|
path_dir = self.root / directory_name
|
||||||
|
path_dir.mkdir()
|
||||||
|
if competing:
|
||||||
|
competitor = path_dir / "xray"
|
||||||
|
competitor.write_bytes(self.fake_xray("competitor"))
|
||||||
|
competitor.chmod(0o755)
|
||||||
|
path = str(path_dir) + os.pathsep + str(self.destination.parent)
|
||||||
|
else:
|
||||||
|
path = str(path_dir)
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "PATH"):
|
||||||
|
self.install(path=path)
|
||||||
|
self.assertTrue(self.destination.is_file())
|
||||||
|
self.destination.unlink()
|
||||||
|
|
||||||
|
def test_busy_install_hint_uses_exception_type_not_message_text(self):
|
||||||
|
globals_ = self.module["acquire_install_lock"].__globals__
|
||||||
|
busy_error = self.module["StartLockBusyError"]("completely changed text")
|
||||||
|
with mock.patch.dict(
|
||||||
|
globals_, {"acquire_start_lock": mock.Mock(side_effect=busy_error)}
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(self.module["ProxyError"], "stop"):
|
||||||
|
self.module["acquire_install_lock"]()
|
||||||
|
|
||||||
|
def test_path_comparison_handles_relative_entry_and_internal_bin_symlink(self):
|
||||||
|
local = self.home / ".local"
|
||||||
|
actual_bin = self.home / "private-bin"
|
||||||
|
local.mkdir()
|
||||||
|
actual_bin.mkdir()
|
||||||
|
(local / "bin").symlink_to(actual_bin, target_is_directory=True)
|
||||||
|
|
||||||
|
self.assertEqual(0, self.install())
|
||||||
|
self.assertTrue((actual_bin / "xray").is_file())
|
||||||
|
|
||||||
|
previous_cwd = Path.cwd()
|
||||||
|
try:
|
||||||
|
os.chdir(str(actual_bin))
|
||||||
|
self.assertEqual(0, self.install(path="."))
|
||||||
|
finally:
|
||||||
|
os.chdir(str(previous_cwd))
|
||||||
|
|
||||||
|
def test_install_dispatches_before_machine_config_loading(self):
|
||||||
|
self.write_destination()
|
||||||
|
config = self.root / "config/proxy"
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
(config / "config").write_text("not an ini\n", encoding="ascii")
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment.update(self.environment)
|
||||||
|
environment["PATH"] = self.environment["PATH"] + os.pathsep + os.environ["PATH"]
|
||||||
|
environment["XDG_CONFIG_HOME"] = str(self.root / "config")
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(CLI), "install"],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=environment,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("уже установлен", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -473,6 +473,24 @@ class ManagedXrayCliTest(unittest.TestCase):
|
|||||||
profile_text,
|
profile_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_live_managed_instance_rejects_install_replace_without_changes(self):
|
||||||
|
profile_text = self.tcp_profile()
|
||||||
|
self.write_profile(profile_text)
|
||||||
|
process = self.start()
|
||||||
|
state_before = self.managed_state_path().read_bytes()
|
||||||
|
xray_before = self.xray.read_bytes()
|
||||||
|
|
||||||
|
result = self.run_cli("install", "--replace")
|
||||||
|
|
||||||
|
self.assertNotEqual(0, result.returncode)
|
||||||
|
self.assertIn("сначала", result.stderr)
|
||||||
|
self.assertIn("stop", result.stderr)
|
||||||
|
self.assertEqual(state_before, self.managed_state_path().read_bytes())
|
||||||
|
self.assertEqual(xray_before, self.xray.read_bytes())
|
||||||
|
self.assertFalse((self.home / ".local/bin/xray").exists())
|
||||||
|
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
|
||||||
|
self.stop_and_collect(process)
|
||||||
|
|
||||||
def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self):
|
def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self):
|
||||||
state_dir = self.state_home / "xray-proxy"
|
state_dir = self.state_home / "xray-proxy"
|
||||||
state_dir.mkdir(parents=True, mode=0o700)
|
state_dir.mkdir(parents=True, mode=0o700)
|
||||||
|
|||||||
@@ -12,18 +12,24 @@ if sys.version_info < (3, 8):
|
|||||||
import argparse
|
import argparse
|
||||||
import configparser
|
import configparser
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import hashlib
|
||||||
import http.client
|
import http.client
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qsl, unquote, urlsplit
|
from urllib.parse import parse_qsl, unquote, urlsplit
|
||||||
|
|
||||||
@@ -33,7 +39,17 @@ 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",)
|
||||||
VERSION_LINE = re.compile(r"^Xray (26\.3\.27)(?: .*)?$")
|
XRAY_VERSION = "26.3.27"
|
||||||
|
VERSION_LINE = re.compile(
|
||||||
|
r"^Xray ({0})(?: .*)?$".format(re.escape(XRAY_VERSION))
|
||||||
|
)
|
||||||
|
XRAY_RELEASE_BASE_URL = (
|
||||||
|
"https://github.com/XTLS/Xray-core/releases/download/v{}/".format(XRAY_VERSION)
|
||||||
|
)
|
||||||
|
MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
||||||
|
MAX_DIGEST_BYTES = 4 * 1024
|
||||||
|
MAX_XRAY_BYTES = 64 * 1024 * 1024
|
||||||
|
STREAM_CHUNK_BYTES = 64 * 1024
|
||||||
QUERY_KEYS = {
|
QUERY_KEYS = {
|
||||||
"type",
|
"type",
|
||||||
"security",
|
"security",
|
||||||
@@ -75,6 +91,10 @@ class ProxyError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StartLockBusyError(ProxyError):
|
||||||
|
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
|
||||||
@@ -580,6 +600,293 @@ def xray_version(path):
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def xray_asset_for_architecture(machine):
|
||||||
|
normalized = machine.lower()
|
||||||
|
if normalized in ("x86_64", "amd64"):
|
||||||
|
return "Xray-linux-64.zip"
|
||||||
|
if normalized in ("aarch64", "arm64"):
|
||||||
|
return "Xray-linux-arm64-v8a.zip"
|
||||||
|
raise ProxyError("архитектура {} не поддерживается установщиком Xray".format(machine))
|
||||||
|
|
||||||
|
|
||||||
|
def download_file(url, destination, max_bytes):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as response:
|
||||||
|
raw_length = response.headers.get("Content-Length")
|
||||||
|
if raw_length is None:
|
||||||
|
declared_length = None
|
||||||
|
elif re.fullmatch(r"[0-9]+", raw_length) is None:
|
||||||
|
raise ProxyError("сервер сообщил неверный Content-Length")
|
||||||
|
else:
|
||||||
|
declared_length = int(raw_length)
|
||||||
|
if declared_length > max_bytes:
|
||||||
|
raise ProxyError("скачиваемый файл превышает допустимый размер")
|
||||||
|
with destination.open("wb") as stream:
|
||||||
|
actual_length = 0
|
||||||
|
while True:
|
||||||
|
read_size = min(
|
||||||
|
STREAM_CHUNK_BYTES, max_bytes - actual_length + 1
|
||||||
|
)
|
||||||
|
chunk = response.read(read_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
actual_length += len(chunk)
|
||||||
|
if actual_length > max_bytes:
|
||||||
|
raise ProxyError("скачиваемый файл превышает допустимый размер")
|
||||||
|
if declared_length is not None and actual_length > declared_length:
|
||||||
|
raise ProxyError(
|
||||||
|
"фактический размер не совпадает с Content-Length"
|
||||||
|
)
|
||||||
|
stream.write(chunk)
|
||||||
|
if declared_length is not None and actual_length != declared_length:
|
||||||
|
raise ProxyError("фактический размер не совпадает с Content-Length")
|
||||||
|
except ProxyError:
|
||||||
|
raise
|
||||||
|
except (OSError, urllib.error.URLError, http.client.HTTPException) as error:
|
||||||
|
raise ProxyError("не удалось скачать официальный Xray: {}".format(error))
|
||||||
|
|
||||||
|
|
||||||
|
def expected_archive_sha256(digest_path):
|
||||||
|
try:
|
||||||
|
with digest_path.open("rb") as stream:
|
||||||
|
digest_bytes = stream.read(MAX_DIGEST_BYTES + 1)
|
||||||
|
except (OSError, UnicodeError) as error:
|
||||||
|
raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error))
|
||||||
|
if len(digest_bytes) > MAX_DIGEST_BYTES:
|
||||||
|
raise ProxyError("файл SHA-256 превышает допустимый размер")
|
||||||
|
try:
|
||||||
|
digest_text = digest_bytes.decode("ascii")
|
||||||
|
except UnicodeError as error:
|
||||||
|
raise ProxyError("не удалось прочитать файл SHA-256: {}".format(error))
|
||||||
|
lines = digest_text.split("\n")
|
||||||
|
if lines and lines[-1] == "":
|
||||||
|
lines.pop()
|
||||||
|
expected_lengths = {
|
||||||
|
"MD5": 32,
|
||||||
|
"SHA1": 40,
|
||||||
|
"SHA2-256": 64,
|
||||||
|
"SHA2-512": 128,
|
||||||
|
}
|
||||||
|
records = {}
|
||||||
|
for line in lines:
|
||||||
|
match = re.fullmatch(
|
||||||
|
r"(MD5|SHA1|SHA2-256|SHA2-512)= ([0-9a-f]+)", line
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
raise ProxyError("файл SHA-256 имеет неожиданный формат")
|
||||||
|
name, value = match.groups()
|
||||||
|
if name in records or len(value) != expected_lengths[name]:
|
||||||
|
raise ProxyError("файл SHA-256 имеет неожиданный формат")
|
||||||
|
records[name] = value
|
||||||
|
if set(records) != set(expected_lengths):
|
||||||
|
raise ProxyError("файл SHA-256 имеет неожиданный формат")
|
||||||
|
return records["SHA2-256"]
|
||||||
|
|
||||||
|
|
||||||
|
def archive_sha256(archive_path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
with archive_path.open("rb") as stream:
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(
|
||||||
|
min(STREAM_CHUNK_BYTES, MAX_ARCHIVE_BYTES - total + 1)
|
||||||
|
)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_ARCHIVE_BYTES:
|
||||||
|
raise ProxyError("архив Xray превышает допустимый размер")
|
||||||
|
digest.update(chunk)
|
||||||
|
except ProxyError:
|
||||||
|
raise
|
||||||
|
except OSError as error:
|
||||||
|
raise ProxyError("не удалось прочитать архив Xray: {}".format(error))
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def extract_xray_candidate(archive_path, candidate_path):
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(str(archive_path)) as archive:
|
||||||
|
members = [item for item in archive.infolist() if item.filename == "xray"]
|
||||||
|
if len(members) != 1 or members[0].is_dir():
|
||||||
|
raise ProxyError("официальный архив не содержит ровно один файл xray")
|
||||||
|
if members[0].file_size > MAX_XRAY_BYTES:
|
||||||
|
raise ProxyError("извлекаемый xray превышает допустимый размер")
|
||||||
|
with archive.open(members[0]) as source:
|
||||||
|
descriptor = os.open(
|
||||||
|
str(candidate_path),
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
with os.fdopen(descriptor, "wb") as destination:
|
||||||
|
extracted = 0
|
||||||
|
while True:
|
||||||
|
chunk = source.read(
|
||||||
|
min(STREAM_CHUNK_BYTES, MAX_XRAY_BYTES - extracted + 1)
|
||||||
|
)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
extracted += len(chunk)
|
||||||
|
if extracted > MAX_XRAY_BYTES:
|
||||||
|
raise ProxyError(
|
||||||
|
"извлекаемый xray превышает допустимый размер"
|
||||||
|
)
|
||||||
|
destination.write(chunk)
|
||||||
|
if extracted != members[0].file_size:
|
||||||
|
raise ProxyError("размер извлечённого xray не совпадает с ZIP")
|
||||||
|
candidate_path.chmod(0o755)
|
||||||
|
except ProxyError:
|
||||||
|
raise
|
||||||
|
except (OSError, KeyError, zipfile.BadZipFile) as error:
|
||||||
|
raise ProxyError("не удалось извлечь xray из проверенного архива: {}".format(error))
|
||||||
|
|
||||||
|
|
||||||
|
def install_destination():
|
||||||
|
return Path.home() / ".local/bin/xray"
|
||||||
|
|
||||||
|
|
||||||
|
def require_install_parent_within_home(parent):
|
||||||
|
try:
|
||||||
|
home = Path.home().resolve(strict=True)
|
||||||
|
resolved_parent = parent.resolve(strict=False)
|
||||||
|
resolved_parent.relative_to(home)
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
raise ProxyError(
|
||||||
|
"каталог установки должен находиться внутри домашнего каталога пользователя"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_install_safe_managed_state():
|
||||||
|
path = state_path()
|
||||||
|
try:
|
||||||
|
path.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
raise ProxyError(
|
||||||
|
"состояние managed-режима не удалось проверить; сначала выполните xray-proxy stop"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
state = read_managed_state()
|
||||||
|
outcome = managed_state_outcome(state)
|
||||||
|
except ProxyError:
|
||||||
|
raise ProxyError(
|
||||||
|
"состояние managed-режима не подтверждено; сначала выполните xray-proxy stop"
|
||||||
|
)
|
||||||
|
if outcome != "stale":
|
||||||
|
raise ProxyError(
|
||||||
|
"managed Xray работает или не подтверждён; сначала выполните xray-proxy stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_install_lock():
|
||||||
|
try:
|
||||||
|
return acquire_start_lock()
|
||||||
|
except StartLockBusyError:
|
||||||
|
raise ProxyError(
|
||||||
|
"другая операция удерживает блокировку; сначала выполните xray-proxy stop"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_installation_outcome(destination, replace):
|
||||||
|
try:
|
||||||
|
info = destination.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "install"
|
||||||
|
except OSError as error:
|
||||||
|
raise ProxyError("не удалось проверить назначение {}: {}".format(destination, error))
|
||||||
|
if stat.S_ISDIR(info.st_mode):
|
||||||
|
raise ProxyError("назначение {} является каталогом".format(destination))
|
||||||
|
if replace:
|
||||||
|
return "install"
|
||||||
|
if stat.S_ISREG(info.st_mode) and os.access(str(destination), os.X_OK):
|
||||||
|
try:
|
||||||
|
xray_version(str(destination))
|
||||||
|
except ProxyError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return "already-installed"
|
||||||
|
raise ProxyError(
|
||||||
|
"назначение {} уже существует; для проверенной замены используйте --replace".format(
|
||||||
|
destination
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_installed_xray_on_path(destination):
|
||||||
|
found = shutil.which("xray")
|
||||||
|
try:
|
||||||
|
matches = found is not None and Path(found).resolve(strict=True) == destination.resolve(
|
||||||
|
strict=True
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
matches = False
|
||||||
|
if not matches:
|
||||||
|
raise ProxyError(
|
||||||
|
"Xray установлен в {}, но PATH выбирает другой файл; добавьте ~/.local/bin в начало PATH".format(
|
||||||
|
destination
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def command_install(replace=False, downloader=download_file):
|
||||||
|
destination = install_destination()
|
||||||
|
lock = acquire_install_lock()
|
||||||
|
try:
|
||||||
|
require_install_safe_managed_state()
|
||||||
|
require_install_parent_within_home(destination.parent)
|
||||||
|
outcome = existing_installation_outcome(destination, replace)
|
||||||
|
if outcome == "already-installed":
|
||||||
|
print("Xray {} уже установлен: {}".format(XRAY_VERSION, destination))
|
||||||
|
else:
|
||||||
|
asset = xray_asset_for_architecture(platform.machine())
|
||||||
|
try:
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o755)
|
||||||
|
except OSError as error:
|
||||||
|
raise ProxyError(
|
||||||
|
"не удалось создать каталог установки {}: {}".format(
|
||||||
|
destination.parent, error
|
||||||
|
)
|
||||||
|
)
|
||||||
|
require_install_parent_within_home(destination.parent)
|
||||||
|
if not destination.parent.is_dir():
|
||||||
|
raise ProxyError(
|
||||||
|
"каталог установки {} не является каталогом".format(destination.parent)
|
||||||
|
)
|
||||||
|
staging = Path(
|
||||||
|
tempfile.mkdtemp(prefix=".xray-install-", dir=str(destination.parent))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
staging.chmod(0o700)
|
||||||
|
archive_path = staging / asset
|
||||||
|
digest_path = staging / (asset + ".dgst")
|
||||||
|
candidate_path = staging / "xray"
|
||||||
|
downloader(
|
||||||
|
XRAY_RELEASE_BASE_URL + asset,
|
||||||
|
archive_path,
|
||||||
|
MAX_ARCHIVE_BYTES,
|
||||||
|
)
|
||||||
|
downloader(
|
||||||
|
XRAY_RELEASE_BASE_URL + asset + ".dgst",
|
||||||
|
digest_path,
|
||||||
|
MAX_DIGEST_BYTES,
|
||||||
|
)
|
||||||
|
expected = expected_archive_sha256(digest_path)
|
||||||
|
if archive_sha256(archive_path) != expected:
|
||||||
|
raise ProxyError("SHA-256 архива Xray не совпадает")
|
||||||
|
extract_xray_candidate(archive_path, candidate_path)
|
||||||
|
xray_version(str(candidate_path))
|
||||||
|
os.replace(str(candidate_path), str(destination))
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(str(staging), ignore_errors=True)
|
||||||
|
print("Xray {} установлен: {}".format(XRAY_VERSION, destination))
|
||||||
|
finally:
|
||||||
|
lock.close()
|
||||||
|
verify_installed_xray_on_path(destination)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def run_xray_test(path, config_path):
|
def run_xray_test(path, config_path):
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
@@ -840,7 +1147,7 @@ def acquire_start_lock():
|
|||||||
except BlockingIOError:
|
except BlockingIOError:
|
||||||
if "lock" in locals():
|
if "lock" in locals():
|
||||||
lock.close()
|
lock.close()
|
||||||
raise ProxyError("другой управляемый запуск уже удерживает блокировку")
|
raise StartLockBusyError("другой управляемый запуск уже удерживает блокировку")
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
if "lock" in locals():
|
if "lock" in locals():
|
||||||
lock.close()
|
lock.close()
|
||||||
@@ -1195,6 +1502,10 @@ def build_parser():
|
|||||||
description="Безопасный запуск команд через явно настроенный прокси.",
|
description="Безопасный запуск команд через явно настроенный прокси.",
|
||||||
)
|
)
|
||||||
subparsers = parser.add_subparsers(dest="subcommand")
|
subparsers = parser.add_subparsers(dest="subcommand")
|
||||||
|
install_parser = subparsers.add_parser("install", help="установить проверенный Xray")
|
||||||
|
install_parser.add_argument(
|
||||||
|
"--replace", action="store_true", help="проверенно заменить существующее назначение"
|
||||||
|
)
|
||||||
subparsers.add_parser("status", help="проверить доступность активного прокси")
|
subparsers.add_parser("status", help="проверить доступность активного прокси")
|
||||||
subparsers.add_parser("env", help="вывести восемь proxy-переменных")
|
subparsers.add_parser("env", help="вывести восемь proxy-переменных")
|
||||||
start_parser = subparsers.add_parser("start", help="запустить управляемый Xray")
|
start_parser = subparsers.add_parser("start", help="запустить управляемый Xray")
|
||||||
@@ -1214,6 +1525,8 @@ def main():
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if arguments.subcommand == "install":
|
||||||
|
return command_install(arguments.replace)
|
||||||
if arguments.subcommand == "stop":
|
if arguments.subcommand == "stop":
|
||||||
return command_stop()
|
return command_stop()
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|||||||
+30
-6
@@ -89,6 +89,7 @@ chmod 600 ~/.config/xray-proxy/default.url
|
|||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
xray-proxy install [--replace]
|
||||||
xray-proxy status
|
xray-proxy status
|
||||||
xray-proxy start [-f PROFILE]
|
xray-proxy start [-f PROFILE]
|
||||||
xray-proxy stop
|
xray-proxy stop
|
||||||
@@ -101,12 +102,32 @@ pcodex [ARG...]
|
|||||||
myip
|
myip
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`install` без root устанавливает единственную поддержанную версию Xray
|
||||||
|
`26.3.27` в `~/.local/bin/xray`. Для `x86_64`/`amd64` используется официальный
|
||||||
|
`Xray-linux-64.zip`, для `aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`.
|
||||||
|
Остальные архитектуры отклоняются. CLI скачивает рядом файл `.dgst`, проверяет
|
||||||
|
единственную запись `SHA2-256= <64 lowercase hex>` в официальном
|
||||||
|
четырёхстрочном формате, извлекает `xray` в закрытый временный каталог внутри
|
||||||
|
`~/.local/bin`, проверяет `xray version` и только затем атомарно меняет
|
||||||
|
назначение. Дубликаты, uppercase, пропущенные и лишние записи отклоняются.
|
||||||
|
Архив и извлечённый бинарник ограничены 64 MiB, `.dgst` — 4 KiB; объявленный
|
||||||
|
`Content-Length` должен точно совпадать с фактически прочитанным размером.
|
||||||
|
|
||||||
|
Существующая корректная версия `26.3.27` даёт успешный no-op без сети. Другой,
|
||||||
|
битый, неисполняемый файл или симлинк не заменяется без `--replace`; каталог не
|
||||||
|
заменяется никогда. Если после установки `PATH` выбирает не этот файл, команда
|
||||||
|
завершается с ошибкой и предлагает поставить `~/.local/bin` в начало `PATH`,
|
||||||
|
но проверенная установка сохраняется. Родительский симлинк принимается, только
|
||||||
|
если разрешённый каталог остаётся внутри `HOME`. Во время живого или
|
||||||
|
неподтверждённого managed-экземпляра установка запрещена: сначала выполните
|
||||||
|
`xray-proxy stop`.
|
||||||
|
|
||||||
Для `external` команды `status`, `env` и `run` проверяют настроенный адрес и
|
Для `external` команды `status`, `env` и `run` проверяют настроенный адрес и
|
||||||
порт. Для `xray` они дополнительно требуют закрытое состояние именно того
|
порт. Для `xray` они дополнительно требуют закрытое состояние именно того
|
||||||
супервизора и дочернего Xray, который запустил CLI. Один открытый порт или чужой
|
супервизора и дочернего Xray, который запустил CLI. Один открытый порт или чужой
|
||||||
PID не принимается. `run` никогда не запускает Xray скрыто.
|
PID не принимается. `run` никогда не запускает Xray скрыто.
|
||||||
|
|
||||||
`start` поддерживает Xray `25.12.8` и `26.3.27`, сначала выполняет
|
`start` поддерживает только Xray `26.3.27`, сначала выполняет
|
||||||
`xray version` и `xray run -test`, затем остаётся на переднем плане. Используйте
|
`xray version` и `xray run -test`, затем остаётся на переднем плане. Используйте
|
||||||
отдельную панель tmux. После готовности один loopback-порт принимает HTTP и
|
отдельную панель tmux. После готовности один loopback-порт принимает HTTP и
|
||||||
SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные
|
SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные
|
||||||
@@ -134,11 +155,14 @@ export PROXY_PREFLIGHT_TIMEOUT=2
|
|||||||
|
|
||||||
Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и
|
Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и
|
||||||
`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но
|
`insecure` не переносит. Истинное legacy-значение даёт предупреждение, но
|
||||||
обычная проверка сертификата остаётся включённой. Xray 25.12.8 предназначен
|
обычная проверка сертификата остаётся включённой. Автоматического перехода
|
||||||
только для явной старой совместимости: согласованное `true` переносится как
|
между версиями или типами прокси нет.
|
||||||
`allowInsecure=true` с предупреждением об отключённой проверке сертификата;
|
|
||||||
`pcs` и `vcn` для этой версии запрещены. Автоматического перехода между
|
Для справки при ручном конфигурировании: последняя версия Xray, где
|
||||||
версиями или типами прокси нет.
|
`allowInsecure` ещё применяется, —
|
||||||
|
[25.12.8](https://github.com/XTLS/Xray-core/releases/tag/v25.12.8); опция
|
||||||
|
удалена в [26.2.6](https://github.com/XTLS/Xray-core/releases/tag/v26.2.6).
|
||||||
|
CLI такие версии не поддерживает и не устанавливает.
|
||||||
|
|
||||||
Сводка скрывает UUID и ключи. Содержимое XHTTP `path` не показывается — только
|
Сводка скрывает UUID и ключи. Содержимое XHTTP `path` не показывается — только
|
||||||
его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге
|
его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге
|
||||||
|
|||||||
Reference in new issue
Block a user