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% входа ревью-этапа: длинные треды с большим
|
||||
|
||||
@@ -306,14 +306,30 @@ CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `r
|
||||
останавливает Xray. Отдельный постоянный журнал и команда `logs` не нужны:
|
||||
достаточно истории tmux.
|
||||
|
||||
Супервизор владеет блокировкой, дочерним процессом и служебным каталогом
|
||||
`~/.local/state/xray-proxy/`. Файл состояния содержит PID и время старта обоих
|
||||
процессов, путь к бинарнику и порт, но не VLESS-ссылку и не её секреты. `status`
|
||||
и `stop` сверяют PID со временем старта и
|
||||
ожидаемым исполняемым файлом, поэтому повторное использование PID не считается
|
||||
управляемым процессом. `stop` посылает SIGTERM супервизору, а тот завершает Xray
|
||||
и очищает состояние. Если супервизор аварийно исчез, `stop` может завершить
|
||||
только дочерний Xray, идентичность которого подтверждена теми же данными.
|
||||
Супервизор на всё время работы удерживает неблокирующую эксклюзивную блокировку
|
||||
`fcntl.flock` в служебном каталоге `~/.local/state/xray-proxy/`. Второй `start`
|
||||
отказывает до запуска процессов и изменения state или сгенерированного
|
||||
конфига. `state.json` нужен для восстановления после сбоя, но не служит
|
||||
блокировкой.
|
||||
|
||||
Файл состояния содержит ровно `supervisor_pid`, `xray_pid`, `xray_path`,
|
||||
`config_path`, `address`, `port`, `supervisor_path`, `supervisor_starttime`,
|
||||
`xray_starttime` и `boot_id`. Время старта — поле 22 `/proc/PID/stat`; поле
|
||||
`comm` сначала отделяется по последней `)`. Для обоих процессов проверяются PID,
|
||||
точное время старта и `boot_id`. У ребёнка дополнительно сверяется канонический
|
||||
`/proc/PID/exe` с `xray_path`. У Python-супервизора операнд скрипта из
|
||||
`/proc/PID/cmdline` разрешается относительно `/proc/PID/cwd` и сравнивается с
|
||||
каноническим `supervisor_path`. Ошибка чтения или разбора означает несовпадение.
|
||||
Непосредственно перед каждым сигналом идентичность читается повторно.
|
||||
|
||||
Проверка состояния различает три исхода. Для здоровой пары `stop` посылает
|
||||
SIGTERM супервизору. Если супервизор исчез, но ребёнок подтверждён, `status`
|
||||
сообщает о сироте с ненулевым кодом, `env` и `run` отказывают, а `stop` посылает
|
||||
сигнал ребёнку напрямую без требования доступности порта и удаляет state и
|
||||
конфиг. При живом, но неподтверждённом PID сигналы не посылаются, файлы
|
||||
сохраняются, а диагностика требует ручной проверки и удаления. Если оба PID
|
||||
мертвы или `boot_id` не совпадает, устаревшие state и конфиг можно безопасно
|
||||
удалить без сигналов.
|
||||
|
||||
У пользователя может быть только один управляемый экземпляр. Повторный `start`
|
||||
не заменяет его другим профилем. Для смены профиля нужны явные `stop` и `start`.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -154,6 +156,88 @@ class XrayProxyCliTest(unittest.TestCase):
|
||||
self.assertIn("требует Python 3.8", result.stderr)
|
||||
self.assertIn("Python 3.7", result.stderr)
|
||||
|
||||
def test_proc_stat_parser_handles_spaces_and_closing_parentheses_in_comm(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
parse_starttime = module["parse_proc_stat_starttime"]
|
||||
tail = ["S"] + [str(value) for value in range(4, 22)] + ["987654"]
|
||||
|
||||
for comm in ("worker", "worker name", "worker name ) with parens"):
|
||||
with self.subTest(comm=comm):
|
||||
self.assertEqual(
|
||||
987654,
|
||||
parse_starttime("321 ({}) ".format(comm) + " ".join(tail)),
|
||||
)
|
||||
for malformed in ("321 worker S 1 2", "321 (worker) S 1 2"):
|
||||
with self.subTest(malformed=malformed):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_starttime(malformed)
|
||||
|
||||
def test_process_permission_error_is_not_treated_as_dead(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
with mock.patch.object(module["Path"], "stat", side_effect=PermissionError()):
|
||||
self.assertTrue(module["process_alive"](12345))
|
||||
|
||||
def test_same_pid_cannot_be_a_healthy_managed_pair(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
function_globals = module["managed_state_outcome"].__globals__
|
||||
state = {"supervisor_pid": 12345, "xray_pid": 12345, "boot_id": "boot"}
|
||||
replacements = {
|
||||
"read_boot_id": mock.Mock(return_value="boot"),
|
||||
"process_alive": mock.Mock(return_value=True),
|
||||
"supervisor_identity_matches": mock.Mock(return_value=True),
|
||||
"xray_identity_matches": mock.Mock(return_value=True),
|
||||
}
|
||||
with mock.patch.dict(function_globals, replacements):
|
||||
self.assertEqual("unverifiable", module["managed_state_outcome"](state))
|
||||
|
||||
def test_identity_reads_starttime_after_process_paths(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
function_globals = module["xray_identity_matches"].__globals__
|
||||
state = {
|
||||
"supervisor_pid": 101,
|
||||
"supervisor_starttime": 201,
|
||||
"supervisor_path": "/cli",
|
||||
"xray_pid": 102,
|
||||
"xray_starttime": 202,
|
||||
"xray_path": "/xray",
|
||||
}
|
||||
|
||||
xray_events = []
|
||||
xray_replacements = {
|
||||
"process_executable": lambda _pid: xray_events.append("exe") or "/xray",
|
||||
"process_starttime": lambda _pid: xray_events.append("stat") or 202,
|
||||
}
|
||||
with mock.patch.dict(function_globals, xray_replacements):
|
||||
self.assertTrue(module["xray_identity_matches"](state))
|
||||
self.assertEqual(["exe", "stat"], xray_events)
|
||||
|
||||
supervisor_events = []
|
||||
supervisor_replacements = {
|
||||
"process_cmdline": lambda _pid: supervisor_events.append("cmdline")
|
||||
or ["python", "/cli"],
|
||||
"process_argument_resolves_to": lambda *_args: supervisor_events.append(
|
||||
"script"
|
||||
)
|
||||
or True,
|
||||
"process_starttime": lambda _pid: supervisor_events.append("stat") or 201,
|
||||
}
|
||||
with mock.patch.dict(function_globals, supervisor_replacements):
|
||||
self.assertTrue(module["supervisor_identity_matches"](state))
|
||||
self.assertEqual(["cmdline", "script", "stat"], supervisor_events)
|
||||
|
||||
def test_initial_child_starttime_retries_while_child_is_alive(self):
|
||||
module = runpy.run_path(str(CLI))
|
||||
wait_for_starttime = module["wait_for_child_starttime"]
|
||||
function_globals = wait_for_starttime.__globals__
|
||||
child = mock.Mock(pid=12345)
|
||||
child.poll.return_value = None
|
||||
replacements = {
|
||||
"process_starttime": mock.Mock(side_effect=[None, 7654321]),
|
||||
"time": mock.Mock(),
|
||||
}
|
||||
with mock.patch.dict(function_globals, replacements):
|
||||
self.assertEqual(7654321, wait_for_starttime(child))
|
||||
|
||||
def test_broken_config_symlink_is_not_treated_as_missing_config(self):
|
||||
config_dir = self.config_home / "proxy"
|
||||
config_dir.mkdir()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -22,8 +24,7 @@ PCS_A = "a" * 64
|
||||
PCS_B = "b" * 64
|
||||
|
||||
|
||||
FAKE_XRAY = r'''#!/usr/bin/env python3
|
||||
import json
|
||||
FAKE_XRAY_OPERAND = r'''import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
@@ -32,21 +33,22 @@ import sys
|
||||
import threading
|
||||
|
||||
log_path = os.environ["FAKE_XRAY_LOG"]
|
||||
operation = os.path.basename(sys.argv[0])
|
||||
|
||||
def record(kind, **values):
|
||||
values["kind"] = kind
|
||||
values["argv"] = sys.argv[1:]
|
||||
values["executable"] = os.path.realpath(sys.argv[0])
|
||||
values["argv"] = [operation] + sys.argv[1:]
|
||||
values["executable"] = os.path.realpath("/proc/self/exe")
|
||||
with open(log_path, "a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(values, sort_keys=True) + "\n")
|
||||
|
||||
if sys.argv[1:] == ["version"]:
|
||||
if operation == "version" and not sys.argv[1:]:
|
||||
record("version")
|
||||
print(os.environ["FAKE_XRAY_VERSION"])
|
||||
raise SystemExit(int(os.environ.get("FAKE_VERSION_EXIT", "0")))
|
||||
|
||||
is_test = len(sys.argv) == 5 and sys.argv[1:4] == ["run", "-test", "-c"]
|
||||
is_run = len(sys.argv) == 4 and sys.argv[1:3] == ["run", "-c"]
|
||||
is_test = operation == "run" and len(sys.argv) == 4 and sys.argv[1:3] == ["-test", "-c"]
|
||||
is_run = operation == "run" and len(sys.argv) == 3 and sys.argv[1] == "-c"
|
||||
if not (is_test or is_run):
|
||||
record("bad-argv")
|
||||
raise SystemExit(64)
|
||||
@@ -73,7 +75,7 @@ if os.environ["FAKE_XRAY_VERSION"].startswith("Xray 26.3.27"):
|
||||
):
|
||||
raise SystemExit(65)
|
||||
|
||||
if sys.argv[2] == "-test":
|
||||
if is_test:
|
||||
record("test", config=config)
|
||||
if os.environ.get("FAKE_TEST_FAIL") == "1":
|
||||
sys.stderr.write(os.environ.get("FAKE_TEST_SECRET", "test failed"))
|
||||
@@ -92,6 +94,8 @@ stopping = False
|
||||
def stop(signum, _frame):
|
||||
global stopping
|
||||
record("signal", signum=signum)
|
||||
if signum == signal.SIGTERM and os.environ.get("FAKE_IGNORE_TERM") == "1":
|
||||
return
|
||||
stopping = True
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
@@ -162,8 +166,10 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
self.bin_dir.mkdir()
|
||||
self.log = self.root / "xray.log"
|
||||
self.xray = self.bin_dir / "xray"
|
||||
self.xray.write_text(FAKE_XRAY, encoding="utf-8")
|
||||
shutil.copy2(sys.executable, self.xray)
|
||||
self.xray.chmod(0o755)
|
||||
for operand in ("version", "run"):
|
||||
(self.root / operand).write_text(FAKE_XRAY_OPERAND, encoding="utf-8")
|
||||
self.environment = os.environ.copy()
|
||||
self.environment.update(
|
||||
{
|
||||
@@ -238,6 +244,7 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
env.update(environment)
|
||||
return subprocess.run(
|
||||
[str(CLI)] + list(args),
|
||||
cwd=str(self.root),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
@@ -264,12 +271,25 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
return []
|
||||
return [json.loads(line) for line in self.log.read_text().splitlines()]
|
||||
|
||||
def managed_state_path(self):
|
||||
return self.state_home / "xray-proxy/state.json"
|
||||
|
||||
def read_state(self):
|
||||
return json.loads(self.managed_state_path().read_text(encoding="utf-8"))
|
||||
|
||||
def write_state(self, state):
|
||||
self.managed_state_path().write_text(
|
||||
json.dumps(state) + "\n", encoding="utf-8"
|
||||
)
|
||||
self.managed_state_path().chmod(0o600)
|
||||
|
||||
def start(self, profile=None):
|
||||
args = [str(CLI), "start"]
|
||||
if profile is not None:
|
||||
args.extend(["-f", str(profile)])
|
||||
process = subprocess.Popen(
|
||||
args,
|
||||
cwd=str(self.root),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
@@ -373,10 +393,13 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
process = self.start()
|
||||
state_path = self.state_home / "xray-proxy/state.json"
|
||||
config_path = self.state_home / "xray-proxy/client.json"
|
||||
lock_path = self.state_home / "xray-proxy/lock"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(0o600, state_path.stat().st_mode & 0o777)
|
||||
self.assertEqual(0o600, config_path.stat().st_mode & 0o777)
|
||||
self.assertEqual(0o600, lock_path.stat().st_mode & 0o777)
|
||||
self.assertEqual("", lock_path.read_text(encoding="ascii"))
|
||||
self.assertEqual(0o700, state_path.parent.stat().st_mode & 0o777)
|
||||
self.assertEqual(str(self.xray.resolve()), state["xray_path"])
|
||||
self.assertEqual("127.0.0.1", state["address"])
|
||||
@@ -389,9 +412,20 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
"config_path",
|
||||
"address",
|
||||
"port",
|
||||
"supervisor_path",
|
||||
"supervisor_starttime",
|
||||
"xray_starttime",
|
||||
"boot_id",
|
||||
},
|
||||
set(state),
|
||||
)
|
||||
self.assertEqual(str(CLI.resolve()), state["supervisor_path"])
|
||||
self.assertIsInstance(state["supervisor_starttime"], int)
|
||||
self.assertIsInstance(state["xray_starttime"], int)
|
||||
self.assertEqual(
|
||||
Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip(),
|
||||
state["boot_id"],
|
||||
)
|
||||
state_text = state_path.read_text(encoding="utf-8")
|
||||
self.assert_no_secrets(state_text, profile_text)
|
||||
stream = config["outbounds"][0]["streamSettings"]
|
||||
@@ -415,6 +449,283 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
argv_text = "\0".join(entry["argv"])
|
||||
self.assert_no_secrets(argv_text, profile_text)
|
||||
|
||||
def test_held_lock_rejects_start_without_changing_managed_files(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
state_dir = self.state_home / "xray-proxy"
|
||||
state_dir.mkdir(parents=True, mode=0o700)
|
||||
state_path = state_dir / "state.json"
|
||||
config_path = state_dir / "client.json"
|
||||
state_path.write_text("state sentinel\n", encoding="ascii")
|
||||
config_path.write_text("config sentinel\n", encoding="ascii")
|
||||
lock_path = state_dir / "lock"
|
||||
with lock_path.open("w", encoding="ascii") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
result = self.run_cli("start")
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("блокиров", result.stderr)
|
||||
self.assertEqual("state sentinel\n", state_path.read_text(encoding="ascii"))
|
||||
self.assertEqual("config sentinel\n", config_path.read_text(encoding="ascii"))
|
||||
self.assertEqual([], self.log_entries())
|
||||
self.assert_no_secrets(
|
||||
result.stdout + result.stderr + lock_path.read_text(encoding="ascii"),
|
||||
profile_text,
|
||||
)
|
||||
|
||||
def test_stale_cleanup_waits_for_start_lock_and_preserves_files(self):
|
||||
state_dir = self.state_home / "xray-proxy"
|
||||
state_dir.mkdir(parents=True, mode=0o700)
|
||||
state = {
|
||||
"supervisor_pid": 999999998,
|
||||
"xray_pid": 999999999,
|
||||
"xray_path": str(self.xray.resolve()),
|
||||
"config_path": str(state_dir / "client.json"),
|
||||
"address": "127.0.0.1",
|
||||
"port": self.port,
|
||||
"supervisor_path": str(CLI.resolve()),
|
||||
"supervisor_starttime": 1,
|
||||
"xray_starttime": 1,
|
||||
"boot_id": Path("/proc/sys/kernel/random/boot_id")
|
||||
.read_text(encoding="ascii")
|
||||
.strip(),
|
||||
}
|
||||
self.write_state(state)
|
||||
config_path = state_dir / "client.json"
|
||||
config_path.write_text("stale sentinel\n", encoding="ascii")
|
||||
lock_path = state_dir / "lock"
|
||||
with lock_path.open("w", encoding="ascii") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
status = self.run_cli("status")
|
||||
|
||||
self.assertNotEqual(0, status.returncode)
|
||||
self.assertIn("блокиров", status.stderr)
|
||||
self.assertTrue(self.managed_state_path().exists())
|
||||
self.assertEqual("stale sentinel\n", config_path.read_text(encoding="ascii"))
|
||||
|
||||
def test_reused_pid_starttime_is_not_signalled_and_state_is_preserved(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
process = self.start()
|
||||
original = self.read_state()
|
||||
changed = dict(original)
|
||||
changed["xray_starttime"] += 1
|
||||
self.write_state(changed)
|
||||
marker = self.root / "reused-pid-child-ran"
|
||||
|
||||
for command in (
|
||||
("status",),
|
||||
("env",),
|
||||
("run", "--", "touch", str(marker)),
|
||||
("stop",),
|
||||
):
|
||||
with self.subTest(command=command):
|
||||
result = self.run_cli(*command)
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("вручную", result.stderr)
|
||||
self.assertTrue(self.managed_state_path().exists())
|
||||
self.assertFalse(marker.exists())
|
||||
self.assertTrue(process.poll() is None)
|
||||
|
||||
self.write_state(original)
|
||||
stdout, stderr = self.stop_and_collect(process)
|
||||
self.assert_no_secrets(stdout + stderr, profile_text)
|
||||
|
||||
def test_wrong_xray_executable_is_not_signalled_and_state_is_preserved(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
process = self.start()
|
||||
original = self.read_state()
|
||||
changed = dict(original)
|
||||
changed["xray_path"] = str(Path("/bin/false").resolve())
|
||||
self.write_state(changed)
|
||||
|
||||
stopped = self.run_cli("stop")
|
||||
self.assertNotEqual(0, stopped.returncode)
|
||||
self.assertIn("вручную", stopped.stderr)
|
||||
self.assertTrue(self.managed_state_path().exists())
|
||||
self.assertIsNone(process.poll())
|
||||
|
||||
self.write_state(original)
|
||||
stdout, stderr = self.stop_and_collect(process)
|
||||
self.assert_no_secrets(stdout + stderr, profile_text)
|
||||
|
||||
def test_boot_id_mismatch_cleans_stale_files_without_signalling_processes(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
process = self.start()
|
||||
state = self.read_state()
|
||||
state["boot_id"] = "00000000-0000-0000-0000-000000000000"
|
||||
self.write_state(state)
|
||||
os.kill(state["supervisor_pid"], signal.SIGKILL)
|
||||
process.wait(timeout=2)
|
||||
|
||||
status = self.run_cli("status")
|
||||
self.assertNotEqual(0, status.returncode)
|
||||
self.assertIn("устаревшее", status.stderr)
|
||||
self.assertFalse(self.managed_state_path().exists())
|
||||
self.assertFalse((self.state_home / "xray-proxy/client.json").exists())
|
||||
self.assertTrue(Path("/proc/{}".format(state["xray_pid"])).exists())
|
||||
|
||||
os.kill(state["xray_pid"], signal.SIGKILL)
|
||||
stdout, stderr = process.communicate(timeout=4)
|
||||
self.assert_no_secrets(stdout + stderr, profile_text)
|
||||
|
||||
def test_dead_pids_allow_stale_state_and_config_cleanup(self):
|
||||
state_dir = self.state_home / "xray-proxy"
|
||||
state_dir.mkdir(parents=True, mode=0o700)
|
||||
state = {
|
||||
"supervisor_pid": 999999998,
|
||||
"xray_pid": 999999999,
|
||||
"xray_path": str(self.xray.resolve()),
|
||||
"config_path": str(state_dir / "client.json"),
|
||||
"address": "127.0.0.1",
|
||||
"port": self.port,
|
||||
"supervisor_path": str(CLI.resolve()),
|
||||
"supervisor_starttime": 1,
|
||||
"xray_starttime": 1,
|
||||
"boot_id": Path("/proc/sys/kernel/random/boot_id")
|
||||
.read_text(encoding="ascii")
|
||||
.strip(),
|
||||
}
|
||||
self.write_state(state)
|
||||
(state_dir / "client.json").write_text("stale\n", encoding="ascii")
|
||||
|
||||
status = self.run_cli("status")
|
||||
self.assertNotEqual(0, status.returncode)
|
||||
self.assertIn("устаревшее", status.stderr)
|
||||
self.assertFalse(self.managed_state_path().exists())
|
||||
self.assertFalse((state_dir / "client.json").exists())
|
||||
|
||||
def test_sigkill_supervisor_stop_terminates_confirmed_orphan_and_cleans(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
process = self.start()
|
||||
state = self.read_state()
|
||||
run_count = len(
|
||||
[entry for entry in self.log_entries() if entry["kind"] == "run"]
|
||||
)
|
||||
alternate_profile = self.write_profile(self.xhttp_profile(), explicit=True)
|
||||
repeated = self.run_cli("start")
|
||||
switched = self.run_cli("start", "-f", str(alternate_profile))
|
||||
for result in (repeated, switched):
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("блокиров", result.stderr)
|
||||
self.assertEqual(
|
||||
run_count,
|
||||
len([entry for entry in self.log_entries() if entry["kind"] == "run"]),
|
||||
)
|
||||
|
||||
lock_path = self.state_home / "xray-proxy/lock"
|
||||
with lock_path.open("r+", encoding="ascii") as lock:
|
||||
with self.assertRaises(OSError):
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
os.kill(state["supervisor_pid"], signal.SIGKILL)
|
||||
process.wait(timeout=2)
|
||||
with lock_path.open("r+", encoding="ascii") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fcntl.flock(lock, fcntl.LOCK_UN)
|
||||
|
||||
status = self.run_cli("status")
|
||||
env = self.run_cli("env")
|
||||
marker = self.root / "orphan-child-ran"
|
||||
run = self.run_cli("run", "--", "touch", str(marker))
|
||||
for result in (status, env, run):
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("сирот", result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
stopped = self.run_cli("stop")
|
||||
self.assertEqual(0, stopped.returncode, stopped.stderr)
|
||||
self.assertIn("сирот", stopped.stdout)
|
||||
self.assertFalse(self.managed_state_path().exists())
|
||||
self.assertFalse((self.state_home / "xray-proxy/client.json").exists())
|
||||
stdout, stderr = process.communicate(timeout=2)
|
||||
self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text)
|
||||
|
||||
self.port = self.free_port()
|
||||
self.write_machine_config("xray")
|
||||
replacement = self.start()
|
||||
replacement_stdout, replacement_stderr = self.stop_and_collect(replacement)
|
||||
self.assert_no_secrets(
|
||||
replacement_stdout + replacement_stderr, profile_text
|
||||
)
|
||||
|
||||
def test_unverifiable_live_child_preserves_state_and_config(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
self.environment["XRAY_PROXY_STOP_TIMEOUT"] = "0.2"
|
||||
process = self.start()
|
||||
state = self.read_state()
|
||||
self.xray.unlink()
|
||||
|
||||
os.kill(state["supervisor_pid"], signal.SIGTERM)
|
||||
process.wait(timeout=3)
|
||||
state_exists = self.managed_state_path().exists()
|
||||
config_path = self.state_home / "xray-proxy/client.json"
|
||||
config_exists = config_path.exists()
|
||||
child_alive = Path("/proc/{}".format(state["xray_pid"])).exists()
|
||||
if child_alive:
|
||||
os.kill(state["xray_pid"], signal.SIGKILL)
|
||||
deadline = time.monotonic() + 2
|
||||
while (
|
||||
Path("/proc/{}".format(state["xray_pid"])).exists()
|
||||
and time.monotonic() < deadline
|
||||
):
|
||||
time.sleep(0.02)
|
||||
stdout, stderr = process.communicate(timeout=2)
|
||||
|
||||
self.assertTrue(child_alive)
|
||||
self.assertTrue(state_exists)
|
||||
self.assertTrue(config_exists)
|
||||
self.assertNotEqual(0, process.returncode)
|
||||
self.assertIn("вручную", stderr)
|
||||
self.assert_no_secrets(stdout + stderr, profile_text)
|
||||
|
||||
def test_orphan_ignoring_sigterm_is_rechecked_killed_and_cleaned(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
self.environment["FAKE_IGNORE_TERM"] = "1"
|
||||
process = self.start()
|
||||
state = self.read_state()
|
||||
os.kill(state["supervisor_pid"], signal.SIGKILL)
|
||||
process.wait(timeout=2)
|
||||
|
||||
stopped = self.run_cli(
|
||||
"stop", environment={"XRAY_PROXY_STOP_TIMEOUT": "0.2"}
|
||||
)
|
||||
self.assertEqual(0, stopped.returncode, stopped.stderr)
|
||||
self.assertFalse(self.managed_state_path().exists())
|
||||
self.assertFalse((self.state_home / "xray-proxy/client.json").exists())
|
||||
signals = [
|
||||
entry["signum"]
|
||||
for entry in self.log_entries()
|
||||
if entry["kind"] == "signal"
|
||||
]
|
||||
self.assertEqual([signal.SIGTERM], signals)
|
||||
stdout, stderr = process.communicate(timeout=2)
|
||||
self.assert_no_secrets(stdout + stderr + stopped.stdout, profile_text)
|
||||
|
||||
def test_occupied_port_fails_without_touching_listener_or_leaving_generated_files(self):
|
||||
profile_text = self.tcp_profile()
|
||||
self.write_profile(profile_text)
|
||||
with socket.socket() as listener:
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("127.0.0.1", self.port))
|
||||
listener.listen(1)
|
||||
result = self.run_cli("start")
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=1):
|
||||
pass
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("порт {}".format(self.port), result.stderr)
|
||||
self.assertIn("занят", result.stderr)
|
||||
self.assertEqual([], self.log_entries())
|
||||
self.assertFalse(self.managed_state_path().exists())
|
||||
self.assertFalse((self.state_home / "xray-proxy/client.json").exists())
|
||||
self.assert_no_secrets(result.stdout + result.stderr, profile_text)
|
||||
|
||||
def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self):
|
||||
invalid_versions = (
|
||||
"Xray 25.12.8",
|
||||
@@ -556,6 +867,7 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
config.write_text('{"value":' + constant + '}\n', encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[str(self.xray), "run", "-test", "-c", str(config)],
|
||||
cwd=str(self.root),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=self.environment,
|
||||
@@ -576,6 +888,7 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
self.write_machine_config("xray")
|
||||
process = subprocess.Popen(
|
||||
[str(CLI), "start"],
|
||||
cwd=str(self.root),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
@@ -744,8 +1057,8 @@ class ManagedXrayCliTest(unittest.TestCase):
|
||||
proxy_bin.mkdir()
|
||||
(proxy_bin / "xray-proxy").symlink_to(CLI)
|
||||
invocations = (
|
||||
([str(CLI)], REPO_ROOT, self.environment),
|
||||
([os.path.relpath(str(CLI), str(REPO_ROOT))], REPO_ROOT, self.environment),
|
||||
([str(CLI)], self.root, self.environment),
|
||||
([os.path.relpath(str(CLI), str(self.root))], self.root, self.environment),
|
||||
(
|
||||
["xray-proxy"],
|
||||
self.root,
|
||||
|
||||
@@ -11,6 +11,7 @@ if sys.version_info < (3, 8):
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import fcntl
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
@@ -63,6 +64,10 @@ STATE_KEYS = {
|
||||
"config_path",
|
||||
"address",
|
||||
"port",
|
||||
"supervisor_path",
|
||||
"supervisor_starttime",
|
||||
"xray_starttime",
|
||||
"boot_id",
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +131,10 @@ def generated_config_path():
|
||||
return state_home() / "client.json"
|
||||
|
||||
|
||||
def lock_path():
|
||||
return state_home() / "lock"
|
||||
|
||||
|
||||
def validate_address(value):
|
||||
try:
|
||||
parsed = ipaddress.ip_address(value)
|
||||
@@ -588,10 +597,54 @@ def run_xray_test(path, config_path):
|
||||
|
||||
def process_alive(pid):
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
Path("/proc/{}".format(pid)).stat()
|
||||
return True
|
||||
except OSError:
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def parse_proc_stat_starttime(text):
|
||||
comm_end = text.rfind(")")
|
||||
if comm_end < 0:
|
||||
raise ValueError("missing comm terminator")
|
||||
fields = text[comm_end + 1 :].split()
|
||||
if len(fields) <= 19:
|
||||
raise ValueError("missing starttime")
|
||||
return int(fields[19])
|
||||
|
||||
|
||||
def process_starttime(pid):
|
||||
try:
|
||||
text = Path("/proc/{}/stat".format(pid)).read_text(encoding="ascii")
|
||||
return parse_proc_stat_starttime(text)
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def process_executable(pid):
|
||||
try:
|
||||
return str(Path("/proc/{}/exe".format(pid)).resolve(strict=True))
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_boot_id():
|
||||
try:
|
||||
value = Path("/proc/sys/kernel/random/boot_id").read_text(
|
||||
encoding="ascii"
|
||||
).strip()
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
return value or None
|
||||
|
||||
|
||||
def require_boot_id():
|
||||
value = read_boot_id()
|
||||
if value is None:
|
||||
raise ProxyError("не удалось определить идентификатор текущей загрузки")
|
||||
return value
|
||||
|
||||
|
||||
def process_cmdline(pid):
|
||||
@@ -602,16 +655,6 @@ def process_cmdline(pid):
|
||||
return [item.decode("utf-8", "surrogateescape") for item in raw.split(b"\0") if item]
|
||||
|
||||
|
||||
def process_parent(pid):
|
||||
try:
|
||||
for line in Path("/proc/{}/status".format(pid)).read_text(encoding="ascii").splitlines():
|
||||
if line.startswith("PPid:"):
|
||||
return int(line.split()[1])
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def process_argument_resolves_to(pid, arguments, expected_path):
|
||||
try:
|
||||
process_cwd = Path("/proc/{}/cwd".format(pid)).resolve(strict=True)
|
||||
@@ -648,34 +691,134 @@ def read_managed_state():
|
||||
raise ProxyError("состояние управляемого Xray не подтверждено")
|
||||
if not isinstance(value, dict) or set(value) != STATE_KEYS:
|
||||
raise ProxyError("состояние управляемого Xray не подтверждено")
|
||||
if not all(isinstance(value[key], int) and not isinstance(value[key], bool) for key in ("supervisor_pid", "xray_pid", "port")):
|
||||
integer_keys = (
|
||||
"supervisor_pid",
|
||||
"xray_pid",
|
||||
"port",
|
||||
"supervisor_starttime",
|
||||
"xray_starttime",
|
||||
)
|
||||
if not all(
|
||||
isinstance(value[key], int) and not isinstance(value[key], bool)
|
||||
for key in integer_keys
|
||||
):
|
||||
raise ProxyError("состояние управляемого Xray не подтверждено")
|
||||
if not all(isinstance(value[key], str) for key in ("xray_path", "config_path", "address")):
|
||||
string_keys = (
|
||||
"xray_path",
|
||||
"config_path",
|
||||
"address",
|
||||
"supervisor_path",
|
||||
"boot_id",
|
||||
)
|
||||
if not all(isinstance(value[key], str) for key in string_keys):
|
||||
raise ProxyError("состояние управляемого Xray не подтверждено")
|
||||
return value
|
||||
|
||||
|
||||
def supervisor_identity_matches(state):
|
||||
pid = state["supervisor_pid"]
|
||||
if pid <= 1:
|
||||
return False
|
||||
arguments = process_cmdline(pid)
|
||||
if len(arguments) < 2:
|
||||
return False
|
||||
if not process_argument_resolves_to(
|
||||
pid, [arguments[1]], Path(state["supervisor_path"])
|
||||
):
|
||||
return False
|
||||
return process_starttime(pid) == state["supervisor_starttime"]
|
||||
|
||||
|
||||
def xray_identity_matches(state):
|
||||
pid = state["xray_pid"]
|
||||
if pid <= 1 or process_executable(pid) != state["xray_path"]:
|
||||
return False
|
||||
return process_starttime(pid) == state["xray_starttime"]
|
||||
|
||||
|
||||
def wait_for_child_starttime(child):
|
||||
while True:
|
||||
value = process_starttime(child.pid)
|
||||
if value is not None:
|
||||
return value
|
||||
if child.poll() is not None:
|
||||
raise ProxyError("Xray завершился до сохранения идентичности процесса")
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def signal_supervisor(state, signal_number):
|
||||
if read_boot_id() != state["boot_id"] or not supervisor_identity_matches(state):
|
||||
return False
|
||||
try:
|
||||
os.kill(state["supervisor_pid"], signal_number)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def signal_xray(state, signal_number):
|
||||
if read_boot_id() != state["boot_id"] or not xray_identity_matches(state):
|
||||
return False
|
||||
try:
|
||||
os.kill(state["xray_pid"], signal_number)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def managed_state_outcome(state):
|
||||
current_boot_id = read_boot_id()
|
||||
if current_boot_id is None:
|
||||
return "unverifiable"
|
||||
if current_boot_id != state["boot_id"]:
|
||||
return "stale"
|
||||
if state["supervisor_pid"] == state["xray_pid"]:
|
||||
return "unverifiable"
|
||||
supervisor_alive = process_alive(state["supervisor_pid"])
|
||||
xray_alive = process_alive(state["xray_pid"])
|
||||
if not supervisor_alive and not xray_alive:
|
||||
return "stale"
|
||||
supervisor_matches = supervisor_identity_matches(state)
|
||||
xray_matches = xray_identity_matches(state)
|
||||
if supervisor_matches and xray_matches:
|
||||
return "healthy"
|
||||
if not supervisor_alive and xray_matches:
|
||||
return "orphan"
|
||||
return "unverifiable"
|
||||
|
||||
|
||||
def cleanup_stale_files():
|
||||
for path in (state_path(), generated_config_path()):
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def unverifiable_state_error():
|
||||
return ProxyError(
|
||||
"личность процесса не подтверждена; сигналы не отправлены, "
|
||||
"удалите state.json и client.json вручную после проверки процессов"
|
||||
)
|
||||
|
||||
|
||||
def verify_managed_state(config=None, require_port=True):
|
||||
state = read_managed_state()
|
||||
supervisor = state["supervisor_pid"]
|
||||
child = state["xray_pid"]
|
||||
if supervisor <= 1 or child <= 1 or supervisor == child:
|
||||
raise ProxyError("личность управляемого Xray не подтверждена")
|
||||
supervisor_args = process_cmdline(supervisor)
|
||||
child_args = process_cmdline(child)
|
||||
cli = Path(__file__).resolve()
|
||||
if (
|
||||
not process_alive(supervisor)
|
||||
or not process_argument_resolves_to(supervisor, supervisor_args, cli)
|
||||
or "start" not in supervisor_args
|
||||
or not process_alive(child)
|
||||
or state["xray_path"] not in child_args
|
||||
or "run" not in child_args
|
||||
or "-c" not in child_args
|
||||
or state["config_path"] not in child_args
|
||||
or process_parent(child) != supervisor
|
||||
):
|
||||
raise ProxyError("личность процесса управляемого Xray не подтверждена")
|
||||
outcome = managed_state_outcome(state)
|
||||
if outcome == "stale":
|
||||
lock = acquire_start_lock()
|
||||
try:
|
||||
state = read_managed_state()
|
||||
outcome = managed_state_outcome(state)
|
||||
if outcome == "stale":
|
||||
cleanup_stale_files()
|
||||
raise ProxyError("устаревшее состояние управляемого Xray очищено")
|
||||
finally:
|
||||
lock.close()
|
||||
if outcome == "orphan":
|
||||
raise ProxyError("обнаружен Xray-сирота; выполните xray-proxy stop")
|
||||
if outcome != "healthy":
|
||||
raise unverifiable_state_error()
|
||||
if config is not None and (
|
||||
config.proxy_type != "xray"
|
||||
or config.address != state["address"]
|
||||
@@ -687,6 +830,26 @@ def verify_managed_state(config=None, require_port=True):
|
||||
return state
|
||||
|
||||
|
||||
def acquire_start_lock():
|
||||
private_directory(state_home())
|
||||
try:
|
||||
descriptor = os.open(str(lock_path()), os.O_RDWR | os.O_CREAT, 0o600)
|
||||
lock = os.fdopen(descriptor, "r+")
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
os.chmod(str(lock_path()), 0o600)
|
||||
except BlockingIOError:
|
||||
if "lock" in locals():
|
||||
lock.close()
|
||||
raise ProxyError("другой управляемый запуск уже удерживает блокировку")
|
||||
except OSError as error:
|
||||
if "lock" in locals():
|
||||
lock.close()
|
||||
elif "descriptor" in locals():
|
||||
os.close(descriptor)
|
||||
raise ProxyError("не удалось открыть файл блокировки: {}".format(error))
|
||||
return lock
|
||||
|
||||
|
||||
def safe_summary(profile, local_port):
|
||||
query = profile.query
|
||||
print(
|
||||
@@ -823,8 +986,39 @@ def command_start(config, profile_file):
|
||||
raise ProxyError("для type=xray поле address должно быть loopback IP-адресом")
|
||||
if not address.is_loopback:
|
||||
raise ProxyError("для type=xray требуется loopback IP-адрес")
|
||||
lock = acquire_start_lock()
|
||||
try:
|
||||
return command_start_locked(config, profile_file, address)
|
||||
finally:
|
||||
lock.close()
|
||||
|
||||
|
||||
def check_port_available(address, port):
|
||||
family = socket.AF_INET6 if address.version == 6 else socket.AF_INET
|
||||
try:
|
||||
with socket.socket(family, socket.SOCK_STREAM) as listener:
|
||||
listener.bind((str(address), port))
|
||||
except OSError:
|
||||
raise ProxyError("локальный порт {} занят или недоступен".format(port))
|
||||
|
||||
|
||||
def command_start_locked(config, profile_file, address):
|
||||
if state_path().exists() or state_path().is_symlink():
|
||||
raise ProxyError("файл состояния уже существует; управляемый запуск отменён")
|
||||
try:
|
||||
existing_state = read_managed_state()
|
||||
except ProxyError:
|
||||
raise unverifiable_state_error()
|
||||
outcome = managed_state_outcome(existing_state)
|
||||
if outcome == "stale":
|
||||
cleanup_stale_files()
|
||||
elif outcome == "orphan":
|
||||
raise ProxyError("обнаружен Xray-сирота; сначала выполните xray-proxy stop")
|
||||
elif outcome == "healthy":
|
||||
raise ProxyError("управляемый Xray уже запущен")
|
||||
else:
|
||||
raise unverifiable_state_error()
|
||||
|
||||
check_port_available(address, config.port)
|
||||
|
||||
profile_path = Path(profile_file) if profile_file else xray_config_home() / "default.url"
|
||||
raw_profile = read_profile(profile_path)
|
||||
@@ -847,9 +1041,15 @@ def command_start(config, profile_file):
|
||||
previous = {}
|
||||
for handled in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
previous[handled] = signal.signal(handled, request_stop)
|
||||
boot_id = require_boot_id()
|
||||
supervisor_path = str(Path(__file__).resolve(strict=True))
|
||||
supervisor_starttime = process_starttime(os.getpid())
|
||||
if supervisor_starttime is None:
|
||||
raise ProxyError("не удалось сохранить идентичность супервизора")
|
||||
child = subprocess.Popen(
|
||||
[executable, "run", "-c", str(generated_config_path())]
|
||||
)
|
||||
xray_starttime = wait_for_child_starttime(child)
|
||||
state = {
|
||||
"supervisor_pid": os.getpid(),
|
||||
"xray_pid": child.pid,
|
||||
@@ -857,6 +1057,10 @@ def command_start(config, profile_file):
|
||||
"config_path": str(generated_config_path()),
|
||||
"address": config.address,
|
||||
"port": config.port,
|
||||
"supervisor_path": supervisor_path,
|
||||
"supervisor_starttime": supervisor_starttime,
|
||||
"xray_starttime": xray_starttime,
|
||||
"boot_id": boot_id,
|
||||
}
|
||||
write_private_json(state_path(), state)
|
||||
deadline = time.monotonic() + max(1.0, preflight_timeout())
|
||||
@@ -872,17 +1076,25 @@ def command_start(config, profile_file):
|
||||
last_error = error
|
||||
time.sleep(0.03)
|
||||
if last_error is not None:
|
||||
raise ProxyError("локальный порт дочернего Xray не стал доступен")
|
||||
raise ProxyError(
|
||||
"локальный порт {} занят или недоступен".format(config.port)
|
||||
)
|
||||
diagnose_ip(config)
|
||||
while child.poll() is None and stopping["signal"] is None:
|
||||
time.sleep(0.05)
|
||||
if stopping["signal"] is not None and child.poll() is None:
|
||||
os.kill(child.pid, stopping["signal"])
|
||||
try:
|
||||
child.wait(timeout=stop_timeout())
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
if not signal_xray(state, stopping["signal"]):
|
||||
if child.poll() is None:
|
||||
raise unverifiable_state_error()
|
||||
else:
|
||||
try:
|
||||
child.wait(timeout=stop_timeout())
|
||||
except subprocess.TimeoutExpired:
|
||||
if not signal_xray(state, signal.SIGKILL):
|
||||
if child.poll() is None:
|
||||
raise unverifiable_state_error()
|
||||
else:
|
||||
child.wait()
|
||||
return_code = child.wait()
|
||||
if stopping["signal"] is not None:
|
||||
return 0
|
||||
@@ -893,13 +1105,22 @@ def command_start(config, profile_file):
|
||||
if "previous" in locals():
|
||||
for handled, handler in previous.items():
|
||||
signal.signal(handled, handler)
|
||||
if "child" in locals() and child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=stop_timeout())
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
preserve_managed_files = False
|
||||
if "child" in locals() and child.poll() is None and "state" in locals():
|
||||
if signal_xray(state, signal.SIGTERM):
|
||||
try:
|
||||
child.wait(timeout=stop_timeout())
|
||||
except subprocess.TimeoutExpired:
|
||||
if signal_xray(state, signal.SIGKILL):
|
||||
child.wait()
|
||||
elif child.poll() is None:
|
||||
preserve_managed_files = True
|
||||
elif child.poll() is None:
|
||||
preserve_managed_files = True
|
||||
elif "child" in locals() and child.poll() is None:
|
||||
preserve_managed_files = True
|
||||
if preserve_managed_files:
|
||||
raise unverifiable_state_error()
|
||||
cleanup_managed_files(os.getpid())
|
||||
|
||||
|
||||
@@ -914,12 +1135,35 @@ def stop_timeout():
|
||||
return value
|
||||
|
||||
|
||||
def command_stop():
|
||||
timeout = stop_timeout()
|
||||
state = verify_managed_state(require_port=True)
|
||||
try:
|
||||
os.kill(state["supervisor_pid"], signal.SIGTERM)
|
||||
except OSError:
|
||||
def stop_managed_state(timeout, state, outcome):
|
||||
if outcome == "stale":
|
||||
cleanup_stale_files()
|
||||
raise ProxyError("устаревшее состояние управляемого Xray очищено")
|
||||
if outcome == "unverifiable":
|
||||
raise unverifiable_state_error()
|
||||
if outcome == "orphan":
|
||||
if not signal_xray(state, signal.SIGTERM):
|
||||
raise ProxyError("личность Xray-сироты изменилась; сигнал не отправлен")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not process_alive(state["xray_pid"]):
|
||||
cleanup_stale_files()
|
||||
print("Xray-сирота остановлен, служебные файлы удалены")
|
||||
return 0
|
||||
time.sleep(0.05)
|
||||
if not signal_xray(state, signal.SIGKILL):
|
||||
raise ProxyError("личность Xray-сироты изменилась; SIGKILL не отправлен")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not process_alive(state["xray_pid"]):
|
||||
cleanup_stale_files()
|
||||
print("Xray-сирота остановлен, служебные файлы удалены")
|
||||
return 0
|
||||
time.sleep(0.05)
|
||||
raise ProxyError("остановка Xray-сироты после SIGKILL превысила таймаут")
|
||||
|
||||
check_endpoint(state["address"], state["port"])
|
||||
if not signal_supervisor(state, signal.SIGTERM):
|
||||
raise ProxyError("не удалось послать сигнал подтверждённому супервизору")
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
@@ -930,6 +1174,21 @@ def command_stop():
|
||||
raise ProxyError("штатная остановка управляемого Xray превысила таймаут")
|
||||
|
||||
|
||||
def command_stop():
|
||||
timeout = stop_timeout()
|
||||
state = read_managed_state()
|
||||
outcome = managed_state_outcome(state)
|
||||
if outcome in ("stale", "orphan"):
|
||||
lock = acquire_start_lock()
|
||||
try:
|
||||
state = read_managed_state()
|
||||
outcome = managed_state_outcome(state)
|
||||
return stop_managed_state(timeout, state, outcome)
|
||||
finally:
|
||||
lock.close()
|
||||
return stop_managed_state(timeout, state, outcome)
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="xray-proxy",
|
||||
|
||||
Reference in new issue
Block a user