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

+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 и состояние лежат в закрытом каталоге