- Зачем: - прежнее имя выпячивало Xray, хотя клиент поддерживает также внешний прокси, а два каталога конфигурации были неочевидны. - Что: - пакет, CLI, конфигурация, состояние, тесты и документация переименованы в proxy-client. - машинный конфиг, профиль и два явных примера собраны в одном пользовательском каталоге. - свежая установка защищена от сворачивания каталога конфигурации внутрь репозитория. - Проверка: - bash tests/test_proxy_client_full.sh. - bash tests/test_install.sh. - ручные start, status и xray run -test; незавершённая сетевая приёмка записана в handoff.
133 lines
11 KiB
Markdown
133 lines
11 KiB
Markdown
# 03 — Безопасный жизненный цикл управляемого процесса
|
||
|
||
**What to build:** Управляемый Xray предсказуемо переживает повторные команды,
|
||
сигналы, устаревшее состояние и аварийное исчезновение супервизора, не
|
||
затрагивая чужие процессы и не оставляя секретных данных вне закрытого
|
||
служебного каталога.
|
||
|
||
**Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение.
|
||
|
||
**Status:** done
|
||
|
||
**Резолюция (2026-07-20):** реализовано и принято. Дельта: CLI 976→1235,
|
||
тесты 437→521 + 799→1112; сьюты: 49 unittest OK + 8 shell PASS (baseline
|
||
34/8). Конвейер: spec-ревью постановки (NEEDS_REWORK, 7 MAJOR + 3 MINOR —
|
||
постановка переписана до реализации) → реализация на medium → саморевью
|
||
(3 находки, починены) → слепые ревью: TASK 1 MINOR + 2 NIT, CODE 4 MAJOR +
|
||
1 MINOR (гонки идентичности/очистки) → триаж: FIXED 8, REJECTED 0 → обе
|
||
перепроверки FIXES_CONFIRMED.
|
||
|
||
## Модель идентичности процессов
|
||
|
||
Идентичность определяется отдельно для ребёнка-Xray и для супервизора;
|
||
«обход argv в поисках любого совпадения» (`verify_managed_state`) заменяется
|
||
этим механизмом.
|
||
|
||
- **Общее для обоих:** PID + время старта. Время старта — поле 22
|
||
(`starttime`) из `/proc/PID/stat`, целое число тиков с момента загрузки;
|
||
хранится в state как есть и сравнивается на точное равенство. Парсер
|
||
обязан сначала изолировать поле `comm` по последней `)` (comm может
|
||
содержать пробелы и скобки) и только потом индексировать поля; любая
|
||
ошибка чтения/разбора трактуется как несовпадение (fail-closed).
|
||
- **Ребёнок Xray:** канонический путь `/proc/PID/exe` (realpath) совпадает
|
||
с сохранённым `xray_path`.
|
||
- **Супервизор:** его exe — интерпретатор Python, поэтому проверяется
|
||
иначе: в state сохраняется канонический путь скрипта CLI
|
||
(`supervisor_path`, realpath на момент старта); при проверке операнд
|
||
скрипта из `/proc/PID/cmdline` резолвится (относительные пути — через
|
||
`/proc/PID/cwd`) и сравнивается с `supervisor_path`. Поддерживаемые формы
|
||
запуска (относительный путь, PATH, симлинк) не ломаются — сравнение
|
||
идёт по realpath.
|
||
- **Границы загрузки:** в state сохраняется `boot_id`
|
||
(`/proc/sys/kernel/random/boot_id`). Несовпадение с текущим означает
|
||
устаревшее состояние: записанные PID трактуются как мёртвые, сигналы не
|
||
посылаются.
|
||
- **Гонка check-then-kill:** непосредственно перед каждым `os.kill`
|
||
идентичность перечитывается заново; несовпадение — отказ без сигнала.
|
||
Остаточная гонка «процесс умер между перечиткой и kill» принята как риск
|
||
модели доверенных пользователей (pidfd не используем: Python 3.8+,
|
||
переносимость).
|
||
|
||
## Чекбоксы
|
||
|
||
- [x] Взаимное исключение: супервизор на всё время жизни держит
|
||
неблокирующий эксклюзивный `fcntl.flock` на lock-файле в служебном
|
||
каталоге. Второй одновременный `start` завершается понятным отказом, не
|
||
запуская процессов и не трогая чужие state/конфиг. `state.json` остаётся
|
||
данными для восстановления после сбоя, а не механизмом блокировки.
|
||
Последовательные повторный `start` и смена профиля при живом экземпляре
|
||
по-прежнему отвергаются.
|
||
- [x] Схема state (права 0600, каталоги 0700) — ровно эти ключи:
|
||
`supervisor_pid`, `xray_pid`, `xray_path`, `config_path`, `address`,
|
||
`port` (существующие) + `supervisor_path`, `supervisor_starttime`,
|
||
`xray_starttime`, `boot_id` (новые). Ссылка VLESS и её секреты в state
|
||
не попадают.
|
||
- [x] `status` и `stop` применяют модель идентичности выше; чужой,
|
||
устаревший или переиспользованный PID не признаётся управляемым
|
||
процессом (в пределах принятой остаточной гонки).
|
||
- [x] Три исхода проверки состояния, у каждого — своё поведение:
|
||
1. **Здоровая пара** (супервизор и ребёнок живы, идентичность
|
||
подтверждена): `stop` сигналит супервизору, как сейчас.
|
||
2. **Сирота** (супервизор исчез, ребёнок жив и подтверждён по
|
||
PID + starttime + boot_id + exe): `stop` сигналит ребёнку напрямую и
|
||
удаляет state и сгенерированный конфиг; доступность порта для этого
|
||
НЕ требуется. `status` — отдельная ненулевая диагностика «сирота»;
|
||
`env` и `run` — отказ.
|
||
3. **Непроверяемое/устаревшее состояние:** если записанный PID жив, но
|
||
идентичность не подтверждена — никаких сигналов, state сохраняется,
|
||
диагностика с инструкцией ручного удаления; если записанные PID
|
||
мертвы (или boot_id не совпал) — безопасная очистка устаревших
|
||
файлов разрешена.
|
||
- [x] Конфликт локального порта: `start` при уже занятом порте завершается
|
||
ненулевым кодом, диагностика называет порт занятым/недоступным, чужой
|
||
слушатель не затронут, свои сгенерированные файлы убраны. Инспекция
|
||
владения слушателем (/proc/net/tcp) запрещена.
|
||
- [x] Секреты: полная VLESS-ссылка никуда не персистится; UUID, ключи,
|
||
секретоподобные значения отсутствуют в stdout, stderr, argv,
|
||
`state.json` и lock-файле. Единственное допустимое место — `client.json`
|
||
с правами 0600 внутри каталога 0700; он удаляется при штатном завершении
|
||
и при `stop` сироты.
|
||
- [x] Автоматические тесты (без root, на обычной dev-машине):
|
||
- блокировка: удержанный извне flock → `start` отказывает, ничего не
|
||
запустив и не изменив;
|
||
- переиспользованный PID моделируется правкой сохранённого `starttime`
|
||
у живого процесса (реальную гонку PID не воспроизводим); отдельно —
|
||
мёртвый PID, неверный исполняемый файл, несовпавший `boot_id`;
|
||
- SIGKILL супервизора → `stop` добивает подтверждённого сироту и чистит
|
||
state/конфиг;
|
||
- занятый порт (заранее открытый сокет) → контракт из чекбокса выше;
|
||
- юнит-тест парсера `/proc/PID/stat` на синтетических `comm` с
|
||
пробелами и `)`;
|
||
- фикстура фейкового Xray адаптирована под проверку `/proc/PID/exe`
|
||
(ребёнок должен быть настоящим исполняемым файлом — например,
|
||
`sys.executable` как «бинарник xray» со скриптами-операндами; детали
|
||
на усмотрение исполнителя);
|
||
- все сохраняемые ниже поведения остаются зелёными.
|
||
|
||
## Сохранить, не переписывать
|
||
|
||
Уже реализовано и покрыто тестами в 02/06 — не переделывать, только не
|
||
сломать: проброс SIGINT/SIGTERM/SIGHUP ребёнку; очистка state и конфига при
|
||
штатном завершении; права 0600/0700; последовательные отказы (повторный
|
||
start, смена профиля, несовпадение машинного адреса/порта со state);
|
||
loopback-only; секреты вне stdout/stderr/argv; foreground-супервизор;
|
||
fail-fast `pclaude`/`pcodex`; внешний путь без машинного конфига.
|
||
|
||
Запрещено восстанавливать срезанное в 06: владение слушателем через
|
||
`/proc/net/tcp`, `profile_fingerprint`, инспекцию zombie-состояний,
|
||
SIGALRM-машинерию, поддержку второй версии Xray.
|
||
|
||
## Не трогать
|
||
|
||
- `bash/.bash_functions`, установщик, README — вне объёма.
|
||
- Никаких коммитов.
|
||
|
||
## Отчёт исполнителя
|
||
|
||
1. Изменённые файлы со строкой «зачем» на каждый.
|
||
2. Команды тестов и счётчики (`python3 -m unittest discover -s tests`,
|
||
`bash tests/test_proxy_launchers.sh`; baseline: 34 OK / 8 PASS — новых
|
||
станет больше, указать числа).
|
||
3. Чекбоксы: закрыт / не закрыт и почему.
|
||
4. Принятые по ходу решения и сомнения, если есть.
|