diff --git a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md b/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md index 6d14e1a..dfc4593 100644 --- a/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md +++ b/.scratch/headless-xray-proxy/issues/04-verified-xray-installation.md @@ -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`. diff --git a/.scratch/headless-xray-proxy/token-ledger.md b/.scratch/headless-xray-proxy/token-ledger.md index 372e42d..fae540c 100644 --- a/.scratch/headless-xray-proxy/token-ledger.md +++ b/.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% входа ревью-этапа: длинные треды с большим diff --git a/docs/specs/2026-07-15-headless-xray-proxy-design.md b/docs/specs/2026-07-15-headless-xray-proxy-design.md index f1fe7f8..05c6829 100644 --- a/docs/specs/2026-07-15-headless-xray-proxy-design.md +++ b/docs/specs/2026-07-15-headless-xray-proxy-design.md @@ -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`. Первая строка должна начинаться с точного diff --git a/tests/test_xray_proxy.py b/tests/test_xray_proxy.py index 63e017a..96c509a 100644 --- a/tests/test_xray_proxy.py +++ b/tests/test_xray_proxy.py @@ -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() diff --git a/tests/test_xray_proxy_managed.py b/tests/test_xray_proxy_managed.py index 456c310..e431d5b 100644 --- a/tests/test_xray_proxy_managed.py +++ b/tests/test_xray_proxy_managed.py @@ -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) diff --git a/xray-proxy/.local/bin/xray-proxy b/xray-proxy/.local/bin/xray-proxy index 35d9c44..c13ee01 100755 --- a/xray-proxy/.local/bin/xray-proxy +++ b/xray-proxy/.local/bin/xray-proxy @@ -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() diff --git a/xray-proxy/README.md b/xray-proxy/README.md index 927e0fd..b151419 100644 --- a/xray-proxy/README.md +++ b/xray-proxy/README.md @@ -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 и состояние лежат в закрытом каталоге