"""Публичный 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())