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
|
||||
|
||||
**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 и принимает другую только через явный параметр
|
||||
версии, не объявляя произвольный релиз поддержанным.
|
||||
- [ ] Архив и файл контрольной суммы скачиваются из официального релиза, после
|
||||
чего SHA-256 проверяется до распаковки и установки; несовпадение прекращает
|
||||
операцию без изменения установленного бинарника.
|
||||
- [ ] Работа ведётся во временном пользовательском каталоге, извлечённый бинарник
|
||||
сначала проходит проверку `version`, а затем атомарно устанавливается в
|
||||
пользовательский каталог исполняемых файлов.
|
||||
- [ ] Существующий бинарник другой версии не перезаписывается без `--replace`;
|
||||
ошибка, отказ или прерывание не оставляют частично установленный файл.
|
||||
- [ ] После установки `command -v xray` обязан указывать на установленный файл;
|
||||
иначе команда завершается с понятной подсказкой исправить `PATH`.
|
||||
- [ ] Установщик не использует sudo, systemd или системные каталоги, а команды
|
||||
`start` и запуск без подкоманды никогда не начинают скачивание.
|
||||
- [ ] Автоматические тесты проверяют обе архитектуры, корректную и повреждённую
|
||||
контрольную сумму, существующую версию, `--replace`, атомарность и отсутствие
|
||||
записи вне изолированного пользовательского каталога.
|
||||
## Зафиксированный протокол установки
|
||||
|
||||
- **Только версия 26.3.27** — та же, что принимает `VERSION_LINE`.
|
||||
Никакого параметра версии: будущий апгрейд — отдельный тикет, меняющий
|
||||
пин скачивания, ворота рантайма, фикстуры и документацию вместе.
|
||||
- **Источник:** `https://github.com/XTLS/Xray-core/releases/download/v26.3.27/`.
|
||||
Маппинг архитектур (чистая функция, по `platform.machine()`):
|
||||
`x86_64`/`amd64` → `Xray-linux-64.zip`; `aarch64`/`arm64` →
|
||||
`Xray-linux-arm64-v8a.zip`; прочее — понятный отказ.
|
||||
- **Проверка:** скачивается `<архив>.dgst`. Фактический формат официальных
|
||||
файлов — четыре записи `MD5=`, `SHA1=`, `SHA2-256=`, `SHA2-512=`
|
||||
(исправлено 2026-07-20 по находке CODE-ревью: однострочного `SHA256=`
|
||||
в релизах нет). Принимается ровно одна запись `SHA2-256= <64 hex в
|
||||
нижнем регистре>`; дубликаты, uppercase, отсутствие записи или мусор —
|
||||
отказ. Хэш архива считается `hashlib.sha256`; несовпадение или
|
||||
неожиданный формат — отказ до распаковки, установленный бинарник не
|
||||
тронут.
|
||||
- **Границы размеров:** скачивание и распаковка ограничены
|
||||
зафиксированными лимитами (архив, `.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: перепроверка 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
|
||||
(ревью постановки 82k/16k, реализация ~248k/29.5k, саморевью 53k/11.6k,
|
||||
CODE-ревью 85k/12.8k, триаж 55.5k/16.9k, перепроверка 112k/3.2k);
|
||||
@@ -49,6 +58,25 @@ Claude-доля — 80.5k суммарно (TASK-ревью + перепрове
|
||||
|
||||
\* 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% входа ревью-этапа: длинные треды с большим
|
||||
|
||||
@@ -266,20 +266,30 @@ Hiddify Manager может добавлять `allowInsecure=true` и `insecure=
|
||||
|
||||
`xray-proxy install` — отдельная явная операция. Она устанавливает Xray в
|
||||
`~/.local/bin`, скачивает официальный архив и файл контрольной суммы, затем
|
||||
проверяет SHA-256. Версия по умолчанию зафиксирована в коде клиента на текущем
|
||||
на момент реализации стабильном релизе 26.3.27. Пользователь может явно указать
|
||||
другую версию через `--version`; произвольная версия не становится заявленной
|
||||
как поддерживаемая только из-за успешной установки. Официальные релизы
|
||||
публикуют необходимые файлы `.dgst`:
|
||||
проверяет SHA-256. Поддерживается только зафиксированный релиз 26.3.27;
|
||||
параметра версии нет. Официальные релизы публикуют необходимые файлы `.dgst`:
|
||||
[релиз 26.3.27](https://github.com/XTLS/Xray-core/releases/tag/v26.3.27).
|
||||
|
||||
Первая версия установщика поддерживает Linux x86_64 и aarch64. Архив
|
||||
распаковывается во временный пользовательский каталог, проверенный бинарник
|
||||
сначала запускается с `version`, а затем атомарно переносится в
|
||||
`~/.local/bin/xray`. Существующий файл другой версии не перезаписывается без
|
||||
явного `--replace`. После установки CLI проверяет, что `command -v xray`
|
||||
указывает именно на установленный файл; иначе завершается с подсказкой исправить
|
||||
`PATH`. `start` всегда показывает фактический путь и версию выбранного бинарника.
|
||||
Для Linux `x86_64`/`amd64` используется `Xray-linux-64.zip`, для
|
||||
`aarch64`/`arm64` — `Xray-linux-arm64-v8a.zip`; остальные архитектуры
|
||||
отклоняются. Официальный `.dgst` содержит записи `MD5`, `SHA1`, `SHA2-256` и
|
||||
`SHA2-512`; принимается ровно один полный набор со строчными hex-значениями,
|
||||
для проверки архива используется `SHA2-256`. Дубликаты, uppercase, отсутствие
|
||||
записи и мусор отклоняются. Архив и извлечённый бинарник ограничены 64 MiB,
|
||||
`.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` до одного пути и
|
||||
запускает этим файлом `xray version`. Первая строка должна начинаться с точного
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
import fcntl
|
||||
import http.client
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
@@ -14,6 +19,31 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
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"):
|
||||
if outcome == "success":
|
||||
outcome_line = " return Connection()\n"
|
||||
@@ -517,5 +547,689 @@ class XrayProxyCliTest(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -473,6 +473,24 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
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):
|
||||
state_dir = self.state_home / "xray-proxy"
|
||||
state_dir.mkdir(parents=True, mode=0o700)
|
||||
|
||||
@@ -12,18 +12,24 @@ if sys.version_info < (3, 8):
|
||||
import argparse
|
||||
import configparser
|
||||
import fcntl
|
||||
import hashlib
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, unquote, urlsplit
|
||||
|
||||
@@ -33,7 +39,17 @@ DEFAULT_NAME = "Hiddify"
|
||||
DEFAULT_ADDRESS = "hproxy.local"
|
||||
DEFAULT_PORT = 12334
|
||||
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 = {
|
||||
"type",
|
||||
"security",
|
||||
@@ -75,6 +91,10 @@ class ProxyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class StartLockBusyError(ProxyError):
|
||||
pass
|
||||
|
||||
|
||||
class ProxyConfig:
|
||||
def __init__(self, proxy_type, name, address, port, no_proxy_extra):
|
||||
self.proxy_type = proxy_type
|
||||
@@ -580,6 +600,293 @@ def xray_version(path):
|
||||
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):
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
@@ -840,7 +1147,7 @@ def acquire_start_lock():
|
||||
except BlockingIOError:
|
||||
if "lock" in locals():
|
||||
lock.close()
|
||||
raise ProxyError("другой управляемый запуск уже удерживает блокировку")
|
||||
raise StartLockBusyError("другой управляемый запуск уже удерживает блокировку")
|
||||
except OSError as error:
|
||||
if "lock" in locals():
|
||||
lock.close()
|
||||
@@ -1195,6 +1502,10 @@ def build_parser():
|
||||
description="Безопасный запуск команд через явно настроенный прокси.",
|
||||
)
|
||||
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("env", help="вывести восемь proxy-переменных")
|
||||
start_parser = subparsers.add_parser("start", help="запустить управляемый Xray")
|
||||
@@ -1214,6 +1525,8 @@ def main():
|
||||
return 2
|
||||
|
||||
try:
|
||||
if arguments.subcommand == "install":
|
||||
return command_install(arguments.replace)
|
||||
if arguments.subcommand == "stop":
|
||||
return command_stop()
|
||||
config = load_config()
|
||||
|
||||
+30
-6
@@ -89,6 +89,7 @@ chmod 600 ~/.config/xray-proxy/default.url
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
xray-proxy install [--replace]
|
||||
xray-proxy status
|
||||
xray-proxy start [-f PROFILE]
|
||||
xray-proxy stop
|
||||
@@ -101,12 +102,32 @@ pcodex [ARG...]
|
||||
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` проверяют настроенный адрес и
|
||||
порт. Для `xray` они дополнительно требуют закрытое состояние именно того
|
||||
супервизора и дочернего Xray, который запустил CLI. Один открытый порт или чужой
|
||||
PID не принимается. `run` никогда не запускает Xray скрыто.
|
||||
|
||||
`start` поддерживает Xray `25.12.8` и `26.3.27`, сначала выполняет
|
||||
`start` поддерживает только Xray `26.3.27`, сначала выполняет
|
||||
`xray version` и `xray run -test`, затем остаётся на переднем плане. Используйте
|
||||
отдельную панель tmux. После готовности один loopback-порт принимает HTTP и
|
||||
SOCKS. `stop` ограниченно ждёт завершения супервизора и Xray, очищает служебные
|
||||
@@ -134,11 +155,14 @@ export PROXY_PREFLIGHT_TIMEOUT=2
|
||||
|
||||
Xray 26.3.27 использует `pcs` и `vcn`, а устаревшие `allowInsecure` и
|
||||
`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` не показывается — только
|
||||
его длина. Сгенерированный JSON и состояние лежат в закрытом каталоге
|
||||
|
||||
Reference in new issue
Block a user