refactor(proxy-client): введён явный контекст запуска
Выбор путей, прав профиля, вывода Xray и подсказки остановки перенесён из глобальных переменных в неизменяемый RuntimeContext. Команды и управление процессом получают контекст явно. Проверка: прежние 72 теста без изменений прошли на Python 3.8 и 3.14; оба shell-набора прошли.
This commit is contained in:
1 parent
384b4f215f
commit
45938cde98
2 files changed
+118
-111
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user