diff --git a/docs/specs/2026-11-02-proxy-client-profiles-service-design.md b/docs/specs/2026-11-02-proxy-client-profiles-service-design.md index 1d620cc..a46a715 100644 --- a/docs/specs/2026-11-02-proxy-client-profiles-service-design.md +++ b/docs/specs/2026-11-02-proxy-client-profiles-service-design.md @@ -148,7 +148,7 @@ Xray нужен другой порт. Установка службы не ме аварии, явная остановка и одновременная работа ручного экземпляра. Изменение текущей машины или её перезагрузка не входят в проверку по умолчанию. -Локальный набор прошёл на Python 3.8 и 3.14: 67 тестов, проверки Bash-команд +Локальный набор прошёл на Python 3.8 и 3.14: 72 теста, проверки Bash-команд и пользовательского установщика. Дополнительно проверены Bash-команды вместе с настоящим CLI и двумя локальными тестовыми портами. Unit прошёл статическую проверку в изолированном корне с тестовыми базовыми target-файлами. diff --git a/proxy-client/.local/bin/proxy-client b/proxy-client/.local/bin/proxy-client index ad1a111..396edc1 100755 --- a/proxy-client/.local/bin/proxy-client +++ b/proxy-client/.local/bin/proxy-client @@ -291,9 +291,12 @@ def load_profile(path): # Права проверяем у конечного файла: у симлинка они ничего не значат. resolved = path.expanduser().resolve(strict=True) if resolved.stat().st_mode & (0o037 if SYSTEM_MODE else 0o077): + 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)) raise ProxyError( - "файл профиля {} доступен другим пользователям; выполните: chmod 600 {}".format( - resolved, shlex.quote(str(resolved)) + "файл профиля {} доступен другим пользователям; выполните: {}".format( + resolved, repair ) ) lines = [line.strip() for line in resolved.read_text(encoding="utf-8").splitlines()] @@ -1046,6 +1049,27 @@ def report_journal_storage(root): storage, "сохраняется" if persistent else "не сохраняется")) +def make_install_directory(path): + if path.is_dir(): + return + make_install_directory(path.parent) + path.mkdir() + path.chmod(0o755) + + +def install_file(target, data, mode): + make_install_directory(target.parent) + descriptor, temporary = tempfile.mkstemp(prefix=".install-", dir=str(target.parent)) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + os.fchmod(stream.fileno(), mode) + os.replace(temporary, str(target)) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + def service_install(args): source = Path(args.config_dir) xray = Path(args.xray) @@ -1073,51 +1097,48 @@ def service_install(args): b'u proxy-client - "Proxy client" /nonexistent /usr/sbin/nologin\n', 0o644), } manifest = root / "usr/local/lib/proxy-client/installed.json" - settings = root / "etc/proxy-client" + pending = manifest.with_name("installed.pending.json") # До любой записи проверяем все назначения и компоненты пути. targets = [root / name for name in files] + [ - manifest, settings, settings / "config", settings / "default.url"] + manifest, pending, settings, settings / "config", settings / "default.url"] for target in targets: if any(part.is_symlink() for part in [target, *target.parents]): raise ProxyError("установка через симлинк запрещена: {}".format(target)) owned = json.loads(manifest.read_text()) if manifest.exists() else {} + interrupted = json.loads(pending.read_text()) if pending.exists() else {} for name in files: target = root / name if target.exists() and ( not target.is_file() or - owned.get(name) != hashlib.sha256(target.read_bytes()).hexdigest() + hashlib.sha256(target.read_bytes()).hexdigest() not in + [owned.get(name)] + interrupted.get(name, []) ): raise ProxyError("чужой или изменённый файл: {}".format(target)) - if not manifest.exists() and settings.exists(): + if not manifest.exists() and not pending.exists() and settings.exists(): raise ProxyError("чужой каталог настроек: {}".format(settings)) - for name, (data, mode) in files.items(): + make_install_directory(manifest.parent) + manifest.parent.chmod(0o755) + # До замены сохраняем допустимые старые и новые хеши. После сбоя повтор + # распознает уже записанную часть, не принимая произвольные чужие файлы. + planned = {} + for name, (data, _) in files.items(): target = root / name - target.parent.mkdir(parents=True, exist_ok=True) - # Атомарная замена позволяет обновить бинарник работающей службы, - # не меняя уже запущенный процесс. - descriptor, temporary = tempfile.mkstemp(prefix=".install-", dir=str(target.parent)) - try: - with os.fdopen(descriptor, "wb") as stream: - stream.write(data) - os.fchmod(stream.fileno(), mode) - os.replace(temporary, str(target)) - finally: - if os.path.exists(temporary): - os.unlink(temporary) - settings = root / "etc/proxy-client" + planned[name] = [hashlib.sha256(data).hexdigest()] + if target.exists(): + planned[name].append(hashlib.sha256(target.read_bytes()).hexdigest()) + install_file(pending, json.dumps(planned).encode(), 0o644) + for name, (data, mode) in files.items(): + install_file(root / name, data, mode) settings.mkdir(parents=True, exist_ok=True) settings.chmod(0o750) for name in ("config", "default.url"): target = settings / name if not target.exists(): - descriptor = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o640) - with os.fdopen(descriptor, "wb") as stream: - stream.write((source / name).read_bytes()) - os.fchmod(stream.fileno(), 0o640) - manifest.write_text(json.dumps({ + install_file(target, (source / name).read_bytes(), 0o640) + install_file(manifest, json.dumps({ name: hashlib.sha256(data).hexdigest() for name, (data, _) in files.items() - })) - manifest.chmod(0o644) + }).encode(), 0o644) + pending.unlink() if not args.root: import grp subprocess.run(["systemd-sysusers", "/usr/lib/sysusers.d/proxy-client.conf"], check=True) diff --git a/proxy-client/README.md b/proxy-client/README.md index c0f2b68..e85f443 100644 --- a/proxy-client/README.md +++ b/proxy-client/README.md @@ -393,7 +393,9 @@ sudo systemctl restart proxy-client Работающий процесс сам не перезапускается. Чужие или вручную изменённые исполняемые файлы и unit-файл установщик не заменяет; изменения параметров systemd делайте через `sudo systemctl edit proxy-client`. Файлы заменяются -по одному атомарно, общего отката всей установки при сбое нет. +по одному атомарно, общего отката всей установки при сбое нет. После +устранения причины сбоя повторите установку: журнал незавершённой установки +позволяет продолжить её, сохраняя проверку принадлежности файлов. Вывод CLI поступает в journald. Сырой вывод Xray в системном режиме подавлен, чтобы сообщения с фрагментами конфигурации не раскрывали секреты; детальные diff --git a/tests/test_proxy_client_managed.py b/tests/test_proxy_client_managed.py index 35f38ea..db0d70b 100644 --- a/tests/test_proxy_client_managed.py +++ b/tests/test_proxy_client_managed.py @@ -143,6 +143,15 @@ class ManagedXrayTest(unittest.TestCase): self.assertEqual(process.returncode, 0) self.assertFalse((runtime / "client.json").exists()) + def test_system_profile_permission_hint_preserves_group_read(self): + self.profile.chmod(0o644) + result = self.run_cli("service", "run", "--config-dir", str(self.config_dir), + "--runtime-dir", str(self.root / "system-runtime"), + "--xray", str(self.xray)) + self.assertEqual(1, result.returncode) + self.assertIn("chmod 640", result.stderr) + self.assertIn("chown root:proxy-client", result.stderr) + def write_machine_config(self, address="127.0.0.1"): self.config_dir.mkdir(parents=True, exist_ok=True) (self.config_dir / "config").write_text( diff --git a/tests/test_proxy_profiles_service.py b/tests/test_proxy_profiles_service.py index 178eceb..16b2a1d 100644 --- a/tests/test_proxy_profiles_service.py +++ b/tests/test_proxy_profiles_service.py @@ -101,3 +101,112 @@ class ProfilesServiceTest(unittest.TestCase): 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: + 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', '192.0.2.1')): + config.write_text(invalid) + result = self.cli(*args) + self.assertNotEqual(0, result.returncode) + self.assertFalse((self.root / 'stage').exists())