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

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