Files
dotfiles/tests/test_proxy_profiles_service.py
ddadmin 203e515479 feat(proxy-client): добавлен мастер установки системной службы
- Зачем: установка с нуля и переход из ручного Xray одной командой.
- Что: план с подтверждением или --yes, скрытый ввод ссылки, проверка Xray,
  перенос настроек, запуск службы и переключение домашнего профиля;
  безопасное продолжение после сбоя и сохранение существующих настроек.
- Проверка: cross-review Claude и исправление находок; по 108 тестов на
  Python 3.8 и 3.14 под PTY, 18 Bash-проверок. После аудита уточнена
  фикстура повторного запуска и отдельно проверена на обеих версиях Python.
  Настоящие службы и настройки машины не изменялись.
2026-11-04 21:15:23 +01:00

354 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Публичный CLI профилей и установка в изолированный корень."""
import os
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
CLI = Path(__file__).resolve().parents[1] / 'proxy-client/.local/bin/proxy-client'
class ProfilesServiceTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.config = self.root / 'config/proxy-client'
self.config.mkdir(parents=True)
self.env = dict(os.environ, HOME=str(self.root), XDG_CONFIG_HOME=str(self.root / 'config'),
XDG_STATE_HOME=str(self.root / 'state'))
def cli(self, *args):
return subprocess.run([str(CLI), *map(str, args)], env=self.env,
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15)
def test_unknown_profile_never_falls_back_even_for_stop(self):
for command in ('status', 'env', 'run', 'start', 'stop'):
result = self.cli('--profile', 'missing', command)
self.assertEqual(result.returncode, 1, result.stderr)
self.assertIn('неизвестный профиль', result.stderr)
result = self.cli('--profile', '../escape', 'stop')
self.assertEqual(result.returncode, 1)
self.assertIn('имя профиля', result.stderr)
def install_args(self):
source = self.root / 'source'
source.mkdir(exist_ok=True)
(source / 'config').write_text('[proxy]\ntype=xray\nname=Service\nno_proxy_extra=\naddress=127.0.0.1\nport=12345\n')
(source / 'default.url').write_text(
'vless://11111111-2222-4333-8444-555555555555@cdn.example:443'
'?type=xhttp&security=tls&encryption=none&sni=cdn.example&fp=chrome&path=%2Ftest\n')
(source / 'default.url').chmod(0o600)
xray = self.root / 'xray'
xray.write_text('#!/bin/sh\n[ "$1" != version ] || echo "Xray 26.10.10 (fake)"\nexit 0\n')
xray.chmod(0o755)
return ['service', 'install', '--config-dir', source, '--xray', xray,
'--root', self.root / 'stage']
def test_staged_install_is_standalone_and_preserves_config(self):
args = self.install_args()
result = self.cli(*args)
self.assertEqual(result.returncode, 0, result.stderr)
stage = self.root / 'stage'
unit = (stage / 'etc/systemd/system/proxy-client.service').read_text()
self.assertIn('User=proxy-client', unit)
self.assertIn('Restart=on-failure', unit)
self.assertIn('RuntimeDirectoryMode=0700', unit)
self.assertIn('StandardOutput=journal', unit)
self.assertNotIn(str(self.root), unit)
self.assertTrue((stage / 'usr/local/lib/proxy-client/xray').is_file())
self.assertTrue((stage / 'usr/lib/sysusers.d/proxy-client.conf').is_file())
config = stage / 'etc/proxy-client/config'
config.write_text(config.read_text() + '# preserved\n')
result = self.cli(*args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('# preserved', config.read_text())
self.assertEqual((stage / 'etc/proxy-client/default.url').stat().st_mode & 0o777, 0o640)
self.assertIn('journald', result.stdout)
def test_install_defaults_copy_current_profile_and_local_xray(self):
args = self.install_args()
source, xray = args[3], args[5]
for name in ('config', 'default.url'):
(source / name).replace(self.config / name)
local_xray = self.root / '.local/bin/xray'
local_xray.parent.mkdir(parents=True)
xray.replace(local_xray)
result = self.cli('service', 'install', '--root', self.root / 'stage')
self.assertEqual(0, result.returncode, result.stderr)
settings = self.root / 'stage/etc/proxy-client'
for name in ('config', 'default.url'):
self.assertEqual((self.config / name).read_bytes(), (settings / name).read_bytes())
self.assertEqual(local_xray.read_bytes(),
(self.root / 'stage/usr/local/lib/proxy-client/xray').read_bytes())
def test_missing_settings_fail_before_sudo(self):
args = self.install_args()
fake = self.root / 'fake-os'
fake.mkdir()
(fake / 'sitecustomize.py').write_text('import os\nos.geteuid = lambda: 1000\n')
log = self.root / 'sudo-called'
sudo = fake / 'sudo'
sudo.write_text('#!/bin/sh\ntouch "$TEST_SUDO_LOG"\nexit 23\n')
sudo.chmod(0o755)
self.env.update(PYTHONPATH=str(fake), TEST_SUDO_LOG=str(log),
PATH=str(fake) + os.pathsep + os.environ['PATH'])
result = self.cli('service', 'install', '--install-only', '--xray', args[5])
self.assertEqual(1, result.returncode, result.stderr)
self.assertIn('настрой', result.stderr)
self.assertIn('терминал', result.stderr)
self.assertNotIn('No such file', result.stderr)
self.assertFalse(log.exists())
def test_install_can_select_named_profile(self):
args = self.install_args()
named = self.config / 'profiles/work'
named.parent.mkdir(parents=True)
args[3].rename(named)
result = self.cli('--profile', 'work', 'service', 'install', '--xray', args[5],
'--root', self.root / 'stage')
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual((named / 'config').read_bytes(),
(self.root / 'stage/etc/proxy-client/config').read_bytes())
ambiguous = self.cli('--profile', 'work', *args)
self.assertEqual(1, ambiguous.returncode)
self.assertIn('--config-dir', ambiguous.stderr)
def test_install_resolves_user_paths_before_sudo(self):
args = self.install_args()
for name in ('config', 'default.url'):
(args[3] / name).replace(self.config / name)
local_xray = self.root / '.local/bin/xray'
local_xray.parent.mkdir(parents=True)
args[5].replace(local_xray)
fake = self.root / 'fake-os'
fake.mkdir()
(fake / 'sitecustomize.py').write_text('import os\nos.geteuid = lambda: 1000\n')
sudo = fake / 'sudo'
sudo.write_text('#!/usr/bin/env python3\nimport json, os, sys\n'
'open(os.environ["TEST_SUDO_ARGS"], "w").write(json.dumps(sys.argv[1:]))\n'
'sys.exit(23)\n')
sudo.chmod(0o755)
log = self.root / 'sudo-args.json'
self.env.update(PYTHONPATH=str(fake), TEST_SUDO_ARGS=str(log),
PATH=str(fake) + os.pathsep + os.environ['PATH'])
result = self.cli('service', 'install', '--install-only', '--enable', '--start')
self.assertEqual(23, result.returncode, result.stderr)
command = json.loads(log.read_text())
self.assertEqual(['service', 'install', '--config-dir', str(self.config),
'--xray', str(local_xray), '--enable', '--start'], command[3:])
self.assertEqual('--', command[0])
self.assertEqual(str(CLI.resolve()), command[2])
def test_direct_sudo_uses_invoking_users_home(self):
args = self.install_args()
source = self.root / '.config/proxy-client'
source.parent.mkdir()
args[3].rename(source)
xray = self.root / '.local/bin/xray'
xray.parent.mkdir(parents=True)
args[5].replace(xray)
fake = self.root / 'fake-os'
fake.mkdir()
(fake / 'sitecustomize.py').write_text(
'import os, pwd, types\nos.geteuid = lambda: 0\n'
'def user(uid):\n'
' assert uid == 12345\n'
' return types.SimpleNamespace(pw_dir=os.environ["TEST_USER_HOME"])\n'
'pwd.getpwuid = user\n')
self.env.pop('XDG_CONFIG_HOME')
self.env.update(HOME=str(self.root / 'root-home'), SUDO_UID='12345',
TEST_USER_HOME=str(self.root), PYTHONPATH=str(fake))
result = self.cli('service', 'install', '--root', self.root / 'stage')
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual((source / 'config').read_bytes(),
(self.root / 'stage/etc/proxy-client/config').read_bytes())
def test_install_refuses_foreign_files_before_writing(self):
args = self.install_args()
target = self.root / 'stage/etc/systemd/system/proxy-client.service'
target.parent.mkdir(parents=True)
target.write_text('foreign unit')
result = self.cli(*args)
self.assertEqual(result.returncode, 1)
self.assertEqual(target.read_text(), 'foreign unit')
self.assertFalse((self.root / 'stage/usr/local/lib/proxy-client/xray').exists())
def test_install_reports_journal_storage_without_changing_policy(self):
args = self.install_args()
policy = self.root / 'stage/etc/systemd/journald.conf.d/storage.conf'
policy.parent.mkdir(parents=True)
policy.write_text('[Journal]\nStorage=volatile\n')
result = self.cli(*args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('Storage=volatile', result.stdout)
self.assertIn('не сохраняется', result.stdout)
self.assertEqual(policy.read_text(), '[Journal]\nStorage=volatile\n')
def test_update_does_not_require_original_home_profile(self):
args = self.install_args()
first = self.cli(*args)
self.assertEqual(0, first.returncode, first.stderr)
for path in (self.root / 'source').iterdir():
path.unlink()
(self.root / 'source').rmdir()
updated = self.cli(*args)
self.assertEqual(0, updated.returncode, updated.stderr)
def test_default_update_preserves_service_settings_without_home_files(self):
args = self.install_args()
first = self.cli(*args)
self.assertEqual(0, first.returncode, first.stderr)
settings = self.root / 'stage/etc/proxy-client'
saved = {name: (settings / name).read_bytes() for name in ('config', 'default.url')}
for path in args[3].iterdir():
path.unlink()
args[3].rmdir()
args[5].unlink()
# В PATH только Python для shebang тестового CLI; Xray там отсутствует.
path = self.root / 'path'
path.mkdir()
(path / 'python3').symlink_to(sys.executable)
self.env['PATH'] = str(path)
updated = self.cli('service', 'install', '--root', self.root / 'stage')
self.assertEqual(0, updated.returncode, updated.stderr)
for name, data in saved.items():
self.assertEqual(data, (settings / name).read_bytes())
def test_unspecified_journal_storage_is_not_guessed(self):
result = self.cli(*self.install_args())
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn('Storage не задан', result.stdout)
def test_restrictive_umask_keeps_service_executable_accessible(self):
args = self.install_args()
result = subprocess.run(['bash', '-c', 'umask 077; exec "$@"', 'bash',
str(CLI), *map(str, args)], env=self.env,
capture_output=True, text=True, timeout=15)
self.assertEqual(0, result.returncode, result.stderr)
stage = self.root / 'stage'
for name in ('usr', 'usr/local', 'usr/local/lib', 'usr/local/lib/proxy-client'):
self.assertEqual(0o755, (stage / name).stat().st_mode & 0o777, name)
def test_interrupted_install_or_update_can_be_retried(self):
args = self.install_args()
injection = self.root / 'fault'
injection.mkdir()
(injection / 'sitecustomize.py').write_text(
'import os, errno\noriginal = os.replace\n'
'def replace(src, dst):\n'
' if str(dst).endswith("/xray"):\n'
' raise OSError(errno.ENOSPC, "synthetic disk full")\n'
' return original(src, dst)\n'
'os.replace = replace\n')
candidate = self.root / 'new-cli'
candidate.write_bytes(CLI.read_bytes() + b'\n# next release\n')
candidate.chmod(0o755)
for updating in (False, True):
with self.subTest(updating=updating):
args[-1] = self.root / ('update' if updating else 'fresh')
if updating:
result = self.cli(*args)
self.assertEqual(0, result.returncode, result.stderr)
command = [str(candidate), *map(str, args)]
failed = subprocess.run(command, env=dict(self.env, PYTHONPATH=str(injection)),
capture_output=True, text=True, timeout=15)
self.assertNotEqual(0, failed.returncode)
retried = subprocess.run(command, env=self.env, capture_output=True,
text=True, timeout=15)
self.assertEqual(0, retried.returncode, retried.stderr)
def test_system_actions_require_explicit_flags(self):
args = self.install_args()[:-2]
stage = self.root / 'system'
fake = self.root / 'system-api'
fake.mkdir()
log = self.root / 'actions'
# Подмена только ОС: корень файловой системы, root/group API и
# внешние systemd-команды. CLI и установщик выполняются целиком.
(fake / 'sitecustomize.py').write_text('''
import os, pathlib, grp, types
stage = os.environ['TEST_INSTALL_ROOT']
OriginalPath = type(pathlib.Path())
class StagedPath:
home = staticmethod(OriginalPath.home)
def __new__(cls, *parts, **kwargs):
if parts == ('/',):
parts = (stage,)
return OriginalPath(*parts, **kwargs)
pathlib.Path = StagedPath
os.geteuid = lambda: 0
def group(name):
assert os.path.exists(os.environ['TEST_INSTALL_ROOT'] + '/group-created')
return types.SimpleNamespace(gr_gid=os.getgid())
grp.getgrnam = group
def chown(path, uid, gid):
assert str(path).startswith(stage + '/')
assert os.path.exists(stage + '/group-created')
with open(os.environ['TEST_ACTION_LOG'], 'a') as stream:
stream.write('chown ' + os.path.basename(path) + '\\n')
os.chown = chown
''')
for command in ('systemd-sysusers', 'systemctl'):
path = fake / command
path.write_text('#!/bin/sh\n'
'printf "%s %s\\n" "${0##*/}" "$*" >> "$TEST_ACTION_LOG"\n'
'if [ "${0##*/}" = systemd-sysusers ]; then\n'
' touch "$TEST_INSTALL_ROOT/group-created"\nfi\n')
path.chmod(0o755)
env = dict(self.env, PYTHONPATH=str(fake), TEST_INSTALL_ROOT=str(stage),
TEST_ACTION_LOG=str(log), PATH=str(fake) + ':' + os.environ['PATH'])
for flags in ([], ['--enable'], ['--start'], ['--enable', '--start']):
with self.subTest(flags=flags):
log.write_text('')
result = subprocess.run([str(CLI), *map(str, args), *flags], env=env,
capture_output=True, text=True, timeout=15)
self.assertEqual(0, result.returncode, result.stderr)
calls = log.read_text().splitlines()
self.assertTrue(calls[0].startswith('systemd-sysusers '), calls)
self.assertEqual(['chown proxy-client', 'chown config', 'chown default.url'], calls[1:4])
self.assertIn('systemctl daemon-reload', calls)
self.assertEqual('--enable' in flags, 'systemctl enable proxy-client.service' in calls)
self.assertEqual('--start' in flags, 'systemctl start proxy-client.service' in calls)
self.assertFalse(any('restart' in call for call in calls))
def test_invalid_service_requests_do_not_install_files(self):
args = self.install_args()
for extra in (['--enable'], ['--start']):
result = self.cli(*args, *extra)
self.assertNotEqual(0, result.returncode)
self.assertFalse((self.root / 'stage').exists())
result = self.cli('--profile', 'work', *args)
self.assertNotEqual(0, result.returncode)
self.assertFalse((self.root / 'stage').exists())
config = self.root / 'source/config'
original = config.read_text()
for invalid in [original.replace('type=xray', 'type=external')] + [
original.replace('127.0.0.1', address)
for address in ('0.0.0.0', '::', '192.0.2.1', '2001:db8::1', 'localhost')]:
config.write_text(invalid)
result = self.cli(*args)
self.assertNotEqual(0, result.returncode)
self.assertFalse((self.root / 'stage').exists())
def test_manual_and_service_run_reject_non_loopback_before_start(self):
args = self.install_args()
config_path = args[3] / 'config'
original = config_path.read_text()
for name in ('config', 'default.url'):
(args[3] / name).replace(self.config / name)
runtime = self.root / 'service-runtime'
commands = [('start',), ('service', 'run', '--config-dir', self.config,
'--runtime-dir', runtime, '--xray', args[5])]
for address in ('0.0.0.0', '::', '192.0.2.1', '2001:db8::1', 'localhost'):
(self.config / 'config').write_text(original.replace('127.0.0.1', address))
for command in commands:
with self.subTest(address=address, command=command):
result = self.cli(*command)
self.assertEqual(1, result.returncode, result.stderr)
self.assertIn('loopback', result.stderr)
self.assertFalse(runtime.exists())
self.assertFalse((self.root / 'state').exists())