refactor(proxy-client): введён явный контекст запуска

Выбор путей, прав профиля, вывода Xray и подсказки остановки перенесён из глобальных переменных в неизменяемый RuntimeContext. Команды и управление процессом получают контекст явно.

Проверка: прежние 72 теста без изменений прошли на Python 3.8 и 3.14; оба shell-набора прошли.
This commit is contained in:
ddadmin committed 2026-11-03 07:17:23 +01:00
1 parent 384b4f215f
commit 45938cde98
2 files changed
+118 -111

No files matched your search

@@ -115,7 +115,10 @@ Xray нужен другой порт. Установка службы не ме
Применяем `codebase-design`: выбор профиля и все его пути должны определяться Применяем `codebase-design`: выбор профиля и все его пути должны определяться
в одном месте, чтобы команды не повторяли правила размещения файлов. в одном месте, чтобы команды не повторяли правила размещения файлов.
Управление процессом использует уже выбранные настройки и расположение файлов. Пути, правила доступа к профилю, вывод Xray и подсказка остановки собраны
в неизменяемый контекст запуска. Он создаётся при выборе пользовательского
или системного режима и явно передаётся функциям. Управление процессом
использует готовые значения, без глобального переключателя режима.
Системная установка скрывает размещение, права и взаимодействие с systemd Системная установка скрывает размещение, права и взаимодействие с systemd
за небольшим интерфейсом; не копирует разбор VLESS и управление Xray. за небольшим интерфейсом; не копирует разбор VLESS и управление Xray.
+114 -110
View File
@@ -30,6 +30,8 @@ import urllib.request
import uuid import uuid
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from dataclasses import dataclass
from typing import Optional
from urllib.parse import parse_qsl, unquote, urlsplit from urllib.parse import parse_qsl, unquote, urlsplit
@@ -120,36 +122,56 @@ class VlessProfile:
return self.query["security"] return self.query["security"]
SELECTED_PROFILE = "default" @dataclass(frozen=True)
SYSTEM_MODE = False class RuntimeContext:
SYSTEM_CONFIG = Path("/etc/proxy-client") """Размещение и правила выбранного запуска; создаётся до выполнения команды."""
SYSTEM_RUNTIME = Path("/run/proxy-client")
SYSTEM_XRAY = Path("/usr/local/lib/proxy-client/xray") config_dir: Path
state_dir: Path
xray_path: Optional[Path]
profile_forbidden_mode: int
profile_repair: str
xray_output: Optional[int]
stop_hint: str
@property
def state_path(self):
return self.state_dir / "state.json"
@property
def generated_config_path(self):
return self.state_dir / "client.json"
def config_home(): def user_context(profile="default"):
if SYSTEM_MODE: if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", profile):
return SYSTEM_CONFIG raise ProxyError("недопустимое имя профиля")
root = os.environ.get("XDG_CONFIG_HOME") config_root = os.environ.get("XDG_CONFIG_HOME")
base = (Path(root).expanduser() if root else Path.home() / ".config") / "proxy-client" state_root = os.environ.get("XDG_STATE_HOME")
return base if SELECTED_PROFILE == "default" else base / "profiles" / SELECTED_PROFILE config_dir = (Path(config_root).expanduser() if config_root else Path.home() / ".config") / "proxy-client"
state_dir = (Path(state_root).expanduser() if state_root else Path.home() / ".local/state") / "proxy-client"
stop_hint = "Ctrl+C или proxy-client stop"
if profile != "default":
config_dir = config_dir / "profiles" / profile
state_dir = state_dir / "profiles" / profile
if not (config_dir / "config").is_file():
raise ProxyError("неизвестный профиль: {}".format(profile))
stop_hint = "Ctrl+C или proxy-client --profile {} stop".format(profile)
return RuntimeContext(config_dir, state_dir, None, 0o077,
"chmod 600 {0}", None, stop_hint)
def state_home(): def service_context(arguments):
if SYSTEM_MODE: config_dir = Path(arguments.config_dir)
return SYSTEM_RUNTIME state_dir = Path(arguments.runtime_dir)
root = os.environ.get("XDG_STATE_HOME") xray_path = Path(arguments.xray)
base = Path(root).expanduser() if root else Path.home() / ".local/state" if not all(path.is_absolute() for path in (config_dir, state_dir, xray_path)):
base = base / "proxy-client" raise ProxyError("системные пути должны быть абсолютными")
return base if SELECTED_PROFILE == "default" else base / "profiles" / SELECTED_PROFILE if not (config_dir / "config").is_file():
raise ProxyError("нет системного config")
return RuntimeContext(config_dir, state_dir, xray_path, 0o037,
def state_path(): "sudo chown root:proxy-client {0}; sudo chmod 640 {0}",
return state_home() / "state.json" subprocess.DEVNULL, "sudo systemctl stop proxy-client")
def generated_config_path():
return state_home() / "client.json"
def validate_address(value): def validate_address(value):
@@ -170,8 +192,8 @@ def validate_address(value):
raise ProxyError("поле address должно быть IP-адресом или простым ASCII-именем") raise ProxyError("поле address должно быть IP-адресом или простым ASCII-именем")
def load_config(): def load_config(context):
path = config_home() / "config" path = context.config_dir / "config"
if not path.exists(): if not path.exists():
if path.is_symlink(): if path.is_symlink():
raise ProxyError("машинный конфиг {} ссылается на отсутствующий файл".format(path)) raise ProxyError("машинный конфиг {} ссылается на отсутствующий файл".format(path))
@@ -238,9 +260,9 @@ def check_reachable(config):
) )
def check_proxy(config): def check_proxy(config, context):
if config.proxy_type == "xray": if config.proxy_type == "xray":
require_supervised_xray(config) require_supervised_xray(config, context)
check_reachable(config) check_reachable(config)
@@ -287,13 +309,12 @@ def write_private_json(path, value):
path.chmod(0o600) path.chmod(0o600)
def load_profile(path): def load_profile(path, context):
# Права проверяем у конечного файла: у симлинка они ничего не значат. # Права проверяем у конечного файла: у симлинка они ничего не значат.
resolved = path.expanduser().resolve(strict=True) resolved = path.expanduser().resolve(strict=True)
if resolved.stat().st_mode & (0o037 if SYSTEM_MODE else 0o077): if resolved.stat().st_mode & context.profile_forbidden_mode:
target = shlex.quote(str(resolved)) target = shlex.quote(str(resolved))
repair = ("sudo chown root:proxy-client {0}; sudo chmod 640 {0}".format(target) repair = context.profile_repair.format(target)
if SYSTEM_MODE else "chmod 600 {}".format(target))
raise ProxyError( raise ProxyError(
"файл профиля {} доступен другим пользователям; выполните: {}".format( "файл профиля {} доступен другим пользователям; выполните: {}".format(
resolved, repair resolved, repair
@@ -547,8 +568,8 @@ def build_xray_config(profile, local_address, local_port):
} }
def resolve_xray(): def resolve_xray(context):
found = str(SYSTEM_XRAY) if SYSTEM_MODE else shutil.which("xray") found = str(context.xray_path) if context.xray_path is not None else shutil.which("xray")
if found is None: if found is None:
raise ProxyError("xray не найден в PATH; установите его: proxy-client install") raise ProxyError("xray не найден в PATH; установите его: proxy-client install")
return str(Path(found).resolve(strict=True)) return str(Path(found).resolve(strict=True))
@@ -623,7 +644,8 @@ def install_xray(destination, downloader):
shutil.rmtree(str(staging), ignore_errors=True) shutil.rmtree(str(staging), ignore_errors=True)
def command_install(replace=False, downloader=download_file): def command_install(replace=False, downloader=download_file, context=None):
context = context if context is not None else user_context()
destination = Path.home() / ".local/bin/xray" destination = Path.home() / ".local/bin/xray"
if destination.is_dir(): if destination.is_dir():
raise ProxyError("назначение {} является каталогом".format(destination)) raise ProxyError("назначение {} является каталогом".format(destination))
@@ -640,7 +662,7 @@ def command_install(replace=False, downloader=download_file):
else: else:
install_xray(destination, downloader) install_xray(destination, downloader)
print("Xray {} установлен: {}".format(XRAY_VERSION, destination)) print("Xray {} установлен: {}".format(XRAY_VERSION, destination))
if managed_xray_running(): if managed_xray_running(context):
print("Работающий Xray остался прежним; перезапустите proxy-client start") print("Работающий Xray остался прежним; перезапустите proxy-client start")
found = shutil.which("xray") found = shutil.which("xray")
if found is None or Path(found).resolve() != destination.resolve(): if found is None or Path(found).resolve() != destination.resolve():
@@ -667,8 +689,8 @@ def run_xray_test(path, config_path):
raise ProxyError("созданная конфигурация не прошла xray run -test") raise ProxyError("созданная конфигурация не прошла xray run -test")
def read_state(): def read_state(context):
path = state_path() path = context.state_path
try: try:
state = json.loads(path.read_text(encoding="utf-8")) state = json.loads(path.read_text(encoding="utf-8"))
keys = ("supervisor_pid", "xray_pid", "xray_path", "address", "port") keys = ("supervisor_pid", "xray_pid", "xray_path", "address", "port")
@@ -701,32 +723,32 @@ def supervisor_running(state):
return b"proxy-client" in cmdline return b"proxy-client" in cmdline
def remove_state_files(): def remove_state_files(context):
for path in (state_path(), generated_config_path()): for path in (context.state_path, context.generated_config_path):
try: try:
path.unlink() path.unlink()
except FileNotFoundError: except FileNotFoundError:
pass pass
def managed_state(): def managed_state(context):
"""Состояние работающего Xray; устаревшие служебные файлы удаляет.""" """Состояние работающего Xray; устаревшие служебные файлы удаляет."""
state = read_state() state = read_state(context)
if not xray_running(state): if not xray_running(state):
remove_state_files() remove_state_files(context)
raise ProxyError("управляемый Xray не запущен; устаревшие служебные файлы удалены") raise ProxyError("управляемый Xray не запущен; устаревшие служебные файлы удалены")
return state return state
def managed_xray_running(): def managed_xray_running(context):
try: try:
return xray_running(read_state()) return xray_running(read_state(context))
except ProxyError: except ProxyError:
return False return False
def require_supervised_xray(config): def require_supervised_xray(config, context):
state = managed_state() state = managed_state(context)
if not supervisor_running(state): if not supervisor_running(state):
raise ProxyError( raise ProxyError(
"Xray работает без proxy-client start (его процесс завершён); " "Xray работает без proxy-client start (его процесс завершён); "
@@ -736,7 +758,7 @@ def require_supervised_xray(config):
raise ProxyError( raise ProxyError(
"Xray запущен на {}:{}, а {} указывает {}:{}; чтобы применить конфиг, " "Xray запущен на {}:{}, а {} указывает {}:{}; чтобы применить конфиг, "
"выполните proxy-client stop, затем proxy-client start".format( "выполните proxy-client stop, затем proxy-client start".format(
state["address"], state["port"], config_home() / "config", state["address"], state["port"], context.config_dir / "config",
config.address, config.port, config.address, config.port,
) )
) )
@@ -772,9 +794,9 @@ def check_port_available(address, port):
raise ProxyError("локальный порт {} занят или недоступен".format(port)) raise ProxyError("локальный порт {} занят или недоступен".format(port))
def acquire_start_lock(): def acquire_start_lock(context):
private_directory(state_home()) private_directory(context.state_dir)
lock = (state_home() / "lock").open("a") lock = (context.state_dir / "lock").open("a")
try: try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
@@ -848,8 +870,8 @@ def interrupt(_signum, _frame):
raise KeyboardInterrupt raise KeyboardInterrupt
def command_status(config): def command_status(config, context):
check_proxy(config) check_proxy(config, context)
if config.proxy_type == "external": if config.proxy_type == "external":
print("{} (external) доступен по адресу {}:{}".format(config.name, config.address, config.port)) print("{} (external) доступен по адресу {}:{}".format(config.name, config.address, config.port))
else: else:
@@ -857,21 +879,21 @@ def command_status(config):
return 0 return 0
def command_env(config): def command_env(config, context):
check_proxy(config) check_proxy(config, context)
lines = ["{}={}".format(key, value) for key, value in environment_values(config)] lines = ["{}={}".format(key, value) for key, value in environment_values(config)]
sys.stdout.write("\n".join(lines) + "\n") sys.stdout.write("\n".join(lines) + "\n")
return 0 return 0
def command_run(config, remainder): def command_run(config, remainder, context):
if not remainder or remainder[0] != "--" or len(remainder) == 1: if not remainder or remainder[0] != "--" or len(remainder) == 1:
raise ProxyError("использование: proxy-client run -- COMMAND ARG...") raise ProxyError("использование: proxy-client run -- COMMAND ARG...")
command = remainder[1:] command = remainder[1:]
if shutil.which(command[0]) is None: if shutil.which(command[0]) is None:
sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0])) sys.stderr.write("Ошибка: команда не найдена в PATH: {}\n".format(command[0]))
return 127 return 127
check_proxy(config) check_proxy(config, context)
child_environment = os.environ.copy() child_environment = os.environ.copy()
child_environment.update(dict(environment_values(config))) child_environment.update(dict(environment_values(config)))
try: try:
@@ -882,10 +904,10 @@ def command_run(config, remainder):
return exit_code(completed.returncode) return exit_code(completed.returncode)
def command_start(config, profile_file): def command_start(config, profile_file, context):
if config.proxy_type != "xray": if config.proxy_type != "xray":
raise ProxyError( raise ProxyError(
"start доступен только для type=xray; измените {}".format(config_home() / "config") "start доступен только для type=xray; измените {}".format(context.config_dir / "config")
) )
try: try:
loopback = ipaddress.ip_address(config.address).is_loopback loopback = ipaddress.ip_address(config.address).is_loopback
@@ -893,43 +915,43 @@ def command_start(config, profile_file):
loopback = False loopback = False
if not loopback: if not loopback:
raise ProxyError("для type=xray поле address должно быть loopback-адресом, например 127.0.0.1") raise ProxyError("для type=xray поле address должно быть loopback-адресом, например 127.0.0.1")
lock = acquire_start_lock() lock = acquire_start_lock(context)
try: try:
return run_managed_xray(config, profile_file) return run_managed_xray(config, profile_file, context)
finally: finally:
lock.close() lock.close()
def run_managed_xray(config, profile_file): def run_managed_xray(config, profile_file, context):
if state_path().exists(): if context.state_path.exists():
if xray_running(read_state()): if xray_running(read_state(context)):
raise ProxyError( raise ProxyError(
"Xray остался от прошлого proxy-client start; сначала выполните proxy-client stop" "Xray остался от прошлого proxy-client start; сначала выполните proxy-client stop"
) )
remove_state_files() remove_state_files(context)
check_port_available(ipaddress.ip_address(config.address), config.port) check_port_available(ipaddress.ip_address(config.address), config.port)
profile_path = Path(profile_file) if profile_file else config_home() / "default.url" profile_path = Path(profile_file) if profile_file else context.config_dir / "default.url"
profile = load_profile(profile_path) profile = load_profile(profile_path, context)
executable = resolve_xray() executable = resolve_xray(context)
check_xray_version(executable) check_xray_version(executable)
write_private_json( write_private_json(
generated_config_path(), build_xray_config(profile, config.address, config.port) context.generated_config_path, build_xray_config(profile, config.address, config.port)
) )
child = None child = None
try: try:
run_xray_test(executable, generated_config_path()) run_xray_test(executable, context.generated_config_path)
print("Xray: {} ({})".format(executable, XRAY_VERSION)) print("Xray: {} ({})".format(executable, XRAY_VERSION))
print_summary(profile, config.port) print_summary(profile, config.port)
# SIGTERM (от proxy-client stop) и SIGHUP обрабатываем как Ctrl+C. # SIGTERM (от proxy-client stop) и SIGHUP обрабатываем как Ctrl+C.
signal.signal(signal.SIGTERM, interrupt) signal.signal(signal.SIGTERM, interrupt)
signal.signal(signal.SIGHUP, interrupt) signal.signal(signal.SIGHUP, interrupt)
child = subprocess.Popen( child = subprocess.Popen(
[executable, "run", "-c", str(generated_config_path())], [executable, "run", "-c", str(context.generated_config_path)],
stdout=subprocess.DEVNULL if SYSTEM_MODE else None, stdout=context.xray_output,
stderr=subprocess.DEVNULL if SYSTEM_MODE else None, stderr=context.xray_output,
) )
write_private_json( write_private_json(
state_path(), context.state_path,
{ {
"supervisor_pid": os.getpid(), "supervisor_pid": os.getpid(),
"xray_pid": child.pid, "xray_pid": child.pid,
@@ -943,15 +965,9 @@ def run_managed_xray(config, profile_file):
): ):
raise ProxyError("Xray не открыл локальный порт {}".format(config.port)) raise ProxyError("Xray не открыл локальный порт {}".format(config.port))
diagnose_ip(config) diagnose_ip(config)
if SYSTEM_MODE:
stop_hint = "sudo systemctl stop proxy-client"
elif SELECTED_PROFILE == "default":
stop_hint = "Ctrl+C или proxy-client stop"
else:
stop_hint = "Ctrl+C или proxy-client --profile {} stop".format(SELECTED_PROFILE)
print( print(
"Управляемый Xray работает на {}:{}; остановка: {}".format( "Управляемый Xray работает на {}:{}; остановка: {}".format(
config.address, config.port, stop_hint config.address, config.port, context.stop_hint
), ),
flush=True, flush=True,
) )
@@ -966,11 +982,11 @@ def run_managed_xray(config, profile_file):
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
child.kill() child.kill()
child.wait() child.wait()
remove_state_files() remove_state_files(context)
def command_stop(): def command_stop(context):
state = managed_state() state = managed_state(context)
# Живой супервизор сам остановит Xray; иначе Xray гасим напрямую. # Живой супервизор сам остановит Xray; иначе Xray гасим напрямую.
running_supervisor = supervisor_running(state) running_supervisor = supervisor_running(state)
target = state["supervisor_pid"] if running_supervisor else state["xray_pid"] target = state["supervisor_pid"] if running_supervisor else state["xray_pid"]
@@ -984,7 +1000,7 @@ def command_stop():
os.kill(state["xray_pid"], signal.SIGKILL) os.kill(state["xray_pid"], signal.SIGKILL)
if not wait_until(stopped, STOP_TIMEOUT): if not wait_until(stopped, STOP_TIMEOUT):
raise ProxyError("управляемый Xray не остановился за {} с".format(2 * STOP_TIMEOUT + 1)) raise ProxyError("управляемый Xray не остановился за {} с".format(2 * STOP_TIMEOUT + 1))
remove_state_files() remove_state_files(context)
print("Управляемый Xray остановлен") print("Управляемый Xray остановлен")
return 0 return 0
@@ -1087,7 +1103,7 @@ def service_install(args):
if config.proxy_type != "xray" or not ipaddress.ip_address(config.address).is_loopback: if config.proxy_type != "xray" or not ipaddress.ip_address(config.address).is_loopback:
raise ProxyError("службе нужен type=xray и loopback-адрес") raise ProxyError("службе нужен type=xray и loopback-адрес")
if not (settings / "default.url").exists(): if not (settings / "default.url").exists():
load_profile(source / "default.url") load_profile(source / "default.url", user_context())
check_xray_version(str(xray)) check_xray_version(str(xray))
files = { files = {
"usr/local/lib/proxy-client/proxy-client": (Path(__file__).read_bytes(), 0o755), "usr/local/lib/proxy-client/proxy-client": (Path(__file__).read_bytes(), 0o755),
@@ -1189,7 +1205,6 @@ def build_parser():
def main(): def main():
global SELECTED_PROFILE, SYSTEM_MODE, SYSTEM_CONFIG, SYSTEM_RUNTIME, SYSTEM_XRAY
parser = build_parser() parser = build_parser()
arguments = parser.parse_args() arguments = parser.parse_args()
if arguments.subcommand is None: if arguments.subcommand is None:
@@ -1203,33 +1218,22 @@ def main():
raise ProxyError("service не использует пользовательские профили") raise ProxyError("service не использует пользовательские профили")
if arguments.service_action == "install": if arguments.service_action == "install":
return service_install(arguments) return service_install(arguments)
SYSTEM_MODE = True context = service_context(arguments)
SYSTEM_CONFIG = Path(arguments.config_dir) return command_start(load_config(context), None, context)
SYSTEM_RUNTIME = Path(arguments.runtime_dir) context = user_context(arguments.profile)
SYSTEM_XRAY = Path(arguments.xray)
if not all(p.is_absolute() for p in (SYSTEM_CONFIG, SYSTEM_RUNTIME, SYSTEM_XRAY)):
raise ProxyError("системные пути должны быть абсолютными")
if not (SYSTEM_CONFIG / "config").is_file():
raise ProxyError("нет системного config")
return command_start(load_config(), None)
SELECTED_PROFILE = arguments.profile
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", SELECTED_PROFILE):
raise ProxyError("недопустимое имя профиля")
if SELECTED_PROFILE != "default" and not (config_home() / "config").is_file():
raise ProxyError("неизвестный профиль: {}".format(SELECTED_PROFILE))
if arguments.subcommand == "install": if arguments.subcommand == "install":
return command_install(arguments.replace) return command_install(arguments.replace, context=context)
if arguments.subcommand == "stop": if arguments.subcommand == "stop":
return command_stop() return command_stop(context)
config = load_config() config = load_config(context)
if arguments.subcommand == "status": if arguments.subcommand == "status":
return command_status(config) return command_status(config, context)
if arguments.subcommand == "env": if arguments.subcommand == "env":
return command_env(config) return command_env(config, context)
if arguments.subcommand == "run": if arguments.subcommand == "run":
return command_run(config, arguments.arguments) return command_run(config, arguments.arguments, context)
if arguments.subcommand == "start": if arguments.subcommand == "start":
return command_start(config, arguments.profile_file) return command_start(config, arguments.profile_file, context)
except (ProxyError, OSError, ValueError, subprocess.CalledProcessError) as error: except (ProxyError, OSError, ValueError, subprocess.CalledProcessError) as error:
sys.stderr.write("Ошибка: {}\n".format(error)) sys.stderr.write("Ошибка: {}\n".format(error))
return 1 return 1