feat(xray-proxy): добавлена проверяемая установка Xray командой install
- Зачем:
- пользователь без root должен получать проверенный бинарник Xray 26.3.27
одной явной командой, без риска для живого managed-экземпляра.
- Что:
- команда install [--replace]: пин 26.3.27, маппинг x86_64/aarch64,
скачивание urllib + проверка SHA2-256 из официального четырёхзаписного
.dgst, staging внутри ~/.local/bin и один атомарный os.replace;
- границы размеров (64 MiB архив/бинарник, 4 KiB .dgst, счёт limit+1,
сверка Content-Length) и guard managed-режима через start-lock;
- типизированная StartLockBusyError, offline-тесты реального загрузчика
через подмену urlopen, снапшоты дерева ФС в отказных путях;
- README/спека обновлены, включая справку о последней версии с
allowInsecure (25.12.8, удалена в 26.2.6); тикет 04 закрыт, леджер
дополнен итогами эксперимента.
- Проверка:
- PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests — 79 OK.
- bash tests/test_proxy_launchers.sh — 8 PASS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90cc8456cd
commit
0432c4ae8a
7 files changed
+1266
-43
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user