Files
dotfiles/.scratch/proxy-client/issues/03-safe-process-lifecycle.md
T
ddadmin 6e431d875e refactor(proxy-client): переименован интерфейс прокси
- Зачем:
  - прежнее имя выпячивало Xray, хотя клиент поддерживает также внешний прокси, а два каталога конфигурации были неочевидны.
- Что:
  - пакет, CLI, конфигурация, состояние, тесты и документация переименованы в proxy-client.
  - машинный конфиг, профиль и два явных примера собраны в одном пользовательском каталоге.
  - свежая установка защищена от сворачивания каталога конфигурации внутрь репозитория.
- Проверка:
  - bash tests/test_proxy_client_full.sh.
  - bash tests/test_install.sh.
  - ручные start, status и xray run -test; незавершённая сетевая приёмка записана в handoff.
2026-07-21 09:31:21 -04:00

133 lines
11 KiB
Markdown
Raw 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.
# 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. Принятые по ходу решения и сомнения, если есть.