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:
ddadminandClaude Fable 5 committed 2026-07-20 13:46:33 -04:00
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`. Первая строка должна начинаться с точного
+714
View File
@@ -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()
+18
View File
@@ -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)
+315 -2
View File
@@ -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
View File
@@ -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 и состояние лежат в закрытом каталоге