feat(xray-proxy): добавлен безопасный жизненный цикл управляемого Xray
- Зачем:
- stop/status опирались на обход argv и могли счесть чужой или
переиспользованный PID управляемым процессом, а параллельный start
и аварийная гибель супервизора не имели определённого поведения.
- Что:
- введена модель идентичности: PID + starttime (поле 22 /proc/PID/stat),
/proc/PID/exe для ребёнка, канонический путь скрипта для супервизора,
boot_id как граница загрузки; перечитка перед каждым сигналом.
- добавлены lifetime-flock супервизора, три исхода проверки состояния
(здоровая пара / подтверждённый сирота / непроверяемое), отказ при
занятом порте пробным bind и очистка stale только под lock.
- тесты расширены до 49 (blank flock, подмена starttime, чужой exe,
boot_id, SIGKILL супервизора, занятый порт, парсер stat); спека
приведена к модели идентичности; тикет 03 закрыт, леджер дополнен.
- Проверка:
- python3 -m unittest discover -s tests — 49 OK.
- bash tests/test_proxy_launchers.sh — 8 PASS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2d303c233
commit
5ace0cfae0
6 files changed
+890
-100
No files matched your search
@@ -1,36 +1,132 @@
|
||||
# 03 — Безопасный жизненный цикл управляемого процесса
|
||||
|
||||
**What to build:** Управляемый Xray предсказуемо переживает повторные команды,
|
||||
сигналы, устаревшее состояние и аварийное исчезновение супервизора, не затрагивая
|
||||
чужие процессы и не оставляя секретные или временные данные.
|
||||
сигналы, устаревшее состояние и аварийное исчезновение супервизора, не
|
||||
затрагивая чужие процессы и не оставляя секретных данных вне закрытого
|
||||
служебного каталога.
|
||||
|
||||
**Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение.
|
||||
|
||||
**Status:** ready-for-agent
|
||||
**Status:** done
|
||||
|
||||
**Мандат идентичности:** идентичность = PID + время старта (`/proc/PID/stat`) +
|
||||
исполняемый файл ребёнка / путь скрипта супервизора; текущий argv-обход заменить
|
||||
**Резолюция (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`) заменяется
|
||||
этим механизмом.
|
||||
|
||||
- [ ] Супервизор владеет пользовательской блокировкой, допускает только один
|
||||
управляемый экземпляр и не заменяет запущенный профиль другим без явных
|
||||
`stop` и нового `start`.
|
||||
- [ ] Состояние с правами `0600` хранит PID и время старта супервизора и Xray,
|
||||
путь к бинарнику и порт, но не ссылку или её секреты; служебные каталоги имеют
|
||||
права `0700`.
|
||||
- [ ] `status` и `stop` сверяют PID со временем старта и ожидаемым исполняемым
|
||||
файлом, поэтому чужой, устаревший и повторно использованный PID никогда не
|
||||
считается управляемым процессом.
|
||||
- [ ] SIGINT, SIGTERM и SIGHUP передаются дочернему Xray; штатное завершение
|
||||
удаляет состояние, сгенерированный JSON и временные файлы.
|
||||
- [ ] Если супервизор аварийно исчез, `stop` завершает оставшийся Xray только
|
||||
после полной проверки его личности и затем безопасно очищает состояние.
|
||||
- [ ] Повторный запуск, смена профиля, конфликт локального порта и несогласованный
|
||||
машинный адрес завершаются понятным отказом без повреждения уже работающего
|
||||
экземпляра.
|
||||
- [ ] Полная VLESS-ссылка и её секреты отсутствуют в stdout, stderr, служебных
|
||||
файлах и аргументах дочерних процессов; временная конфигурация существует
|
||||
только внутри закрытого служебного каталога.
|
||||
- [ ] Автоматические тесты покрывают блокировку, повторный запуск, сигналы,
|
||||
остановку, очистку, смену профиля, конфликт порта, чужой, устаревший и
|
||||
повторно использованный PID, а также исчезнувший супервизор.
|
||||
- **Общее для обоих:** 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. Принятые по ходу решения и сомнения, если есть.
|
||||
@@ -27,6 +27,28 @@ output/reasoning), кросс-сравнение — ориентировочн
|
||||
|
||||
_Заполняется по ходу; сравнивать построчно с 06._
|
||||
|
||||
| Этап | Агент / модель | Effort | Токены | Прочее |
|
||||
|---|---|---|---|---|
|
||||
| 03: ревью постановки | Codex gpt-5.6-sol | high | in 1 126 384 (кэш 1 044 480 → свежих ~82k), out 15 830 (reasoning 9 567) | NEEDS_REWORK: 7 MAJOR + 3 MINOR |
|
||||
| 03: реализация | Codex gpt-5.6-sol | **medium** | in 5 925 714 (кэш 5 678 080 → свежих ~248k), out 29 512 (reasoning 11 251)* | 88+6 команд; +657/−89; 44 OK + 8 PASS; процесс убит рестартом сессии перед отчётом, дописан через resume |
|
||||
|
||||
| 03: саморевью | Codex (resume) | medium | инкремент: свежих in ~53k, out ~11.6k (reasoning ~7k) | 38 команд; found 3 fixed 3; 45 OK + 8 PASS |
|
||||
| 03: слепое ревью «по задаче» | Claude Opus (субагент) | default | 73 131 суммарно | 21 tool uses, 6.3 мин; 1 MINOR + 2 NIT |
|
||||
| 03: слепое ревью «по коду» | Codex gpt-5.6-sol (свежий тред) | high | in 1 103 725 (кэш 1 019 136 → свежих ~85k), out 12 797 (reasoning 8 908) | 28 команд; 4 MAJOR + 1 MINOR |
|
||||
| 03: триаж/починка | Codex (resume) | medium | инкремент: свежих in ~55.5k, out ~16.9k (reasoning ~7.8k) | 46 команд; FIXED 8, REJECTED 0; 49 OK + 8 PASS |
|
||||
| 03: перепроверка TASK-1..3 | Claude Opus (резюм субагента) | default | инкремент ~7.4k (суммарно 80 560) | 8 tool uses, 1.7 мин; FIXES_CONFIRMED |
|
||||
| 03: перепроверка CODE-1..5 | Codex-ревьюер (resume) | medium | инкремент: свежих in ~112k, out ~3.2k | 20 команд; FIXES_CONFIRMED |
|
||||
|
||||
Итог 03 (реализация на MEDIUM): Codex — свежий input ~636k + output ~77k
|
||||
(ревью постановки 82k/16k, реализация ~248k/29.5k, саморевью 53k/11.6k,
|
||||
CODE-ревью 85k/12.8k, триаж 55.5k/16.9k, перепроверка 112k/3.2k);
|
||||
Claude-доля — 80.5k суммарно (TASK-ревью + перепроверка). Против 06
|
||||
(high-baseline: ~735k + 97k Codex, 175k Claude) — дешевле, но качество
|
||||
ниже: слепые ревью нашли 4 MAJOR (у 06 — 1), все — гонки
|
||||
конкурентности/восстановления; конвейер их выловил и починил.
|
||||
|
||||
\* usage первого (убитого) хода не был зафиксирован (нет `turn.completed`); цифры — из resume-хода, по масштабу покрывают весь тред.
|
||||
|
||||
## Наблюдения
|
||||
|
||||
- Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим
|
||||
|
||||
Reference in new issue
Block a user