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:
ddadminandClaude Fable 5 committed 2026-07-20 07:02:33 -04:00
1 parent f2d303c233
commit 5ace0cfae0
6 files changed
+882 -92

No files matched your search

@@ -1,36 +1,132 @@
# 03 — Безопасный жизненный цикл управляемого процесса # 03 — Безопасный жизненный цикл управляемого процесса
**What to build:** Управляемый Xray предсказуемо переживает повторные команды, **What to build:** Управляемый Xray предсказуемо переживает повторные команды,
сигналы, устаревшее состояние и аварийное исчезновение супервизора, не затрагивая сигналы, устаревшее состояние и аварийное исчезновение супервизора, не
чужие процессы и не оставляя секретные или временные данные. затрагивая чужие процессы и не оставляя секретных данных вне закрытого
служебного каталога.
**Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение. **Blocked by:** 02 — Управляемый Xray: полный штатный путь; 06 — Упрощение.
**Status:** ready-for-agent **Status:** done
**Мандат идентичности:** идентичность = PID + время старта (`/proc/PID/stat`) + **Резолюция (2026-07-20):** реализовано и принято. Дельта: CLI 976→1235,
исполняемый файл ребёнка / путь скрипта супервизора; текущий argv-обход заменить тесты 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`, целое число тиков с момента загрузки;
`stop` и нового `start`. хранится в state как есть и сравнивается на точное равенство. Парсер
- [ ] Состояние с правами `0600` хранит PID и время старта супервизора и Xray, обязан сначала изолировать поле `comm` по последней `)` (comm может
путь к бинарнику и порт, но не ссылку или её секреты; служебные каталоги имеют содержать пробелы и скобки) и только потом индексировать поля; любая
права `0700`. ошибка чтения/разбора трактуется как несовпадение (fail-closed).
- [ ] `status` и `stop` сверяют PID со временем старта и ожидаемым исполняемым - **Ребёнок Xray:** канонический путь `/proc/PID/exe` (realpath) совпадает
файлом, поэтому чужой, устаревший и повторно использованный PID никогда не с сохранённым `xray_path`.
считается управляемым процессом. - **Супервизор:** его exe — интерпретатор Python, поэтому проверяется
- [ ] SIGINT, SIGTERM и SIGHUP передаются дочернему Xray; штатное завершение иначе: в state сохраняется канонический путь скрипта CLI
удаляет состояние, сгенерированный JSON и временные файлы. (`supervisor_path`, realpath на момент старта); при проверке операнд
- [ ] Если супервизор аварийно исчез, `stop` завершает оставшийся Xray только скрипта из `/proc/PID/cmdline` резолвится (относительные пути — через
после полной проверки его личности и затем безопасно очищает состояние. `/proc/PID/cwd`) и сравнивается с `supervisor_path`. Поддерживаемые формы
- [ ] Повторный запуск, смена профиля, конфликт локального порта и несогласованный запуска (относительный путь, PATH, симлинк) не ломаются — сравнение
машинный адрес завершаются понятным отказом без повреждения уже работающего идёт по realpath.
экземпляра. - **Границы загрузки:** в state сохраняется `boot_id`
- [ ] Полная VLESS-ссылка и её секреты отсутствуют в stdout, stderr, служебных (`/proc/sys/kernel/random/boot_id`). Несовпадение с текущим означает
файлах и аргументах дочерних процессов; временная конфигурация существует устаревшее состояние: записанные PID трактуются как мёртвые, сигналы не
только внутри закрытого служебного каталога. посылаются.
- [ ] Автоматические тесты покрывают блокировку, повторный запуск, сигналы, - **Гонка check-then-kill:** непосредственно перед каждым `os.kill`
остановку, очистку, смену профиля, конфликт порта, чужой, устаревший и идентичность перечитывается заново; несовпадение — отказ без сигнала.
повторно использованный PID, а также исчезнувший супервизор. Остаточная гонка «процесс умер между перечиткой и 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._ _Заполняется по ходу; сравнивать построчно с 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% входа ревью-этапа: длинные треды с большим - Кэш у Codex покрывает ~93% входа ревью-этапа: длинные треды с большим
@@ -306,14 +306,30 @@ CLI предоставляет `install`, `start`, `stop`, `status`, `env` и `r
останавливает Xray. Отдельный постоянный журнал и команда `logs` не нужны: останавливает Xray. Отдельный постоянный журнал и команда `logs` не нужны:
достаточно истории tmux. достаточно истории tmux.
Супервизор владеет блокировкой, дочерним процессом и служебным каталогом Супервизор на всё время работы удерживает неблокирующую эксклюзивную блокировку
`~/.local/state/xray-proxy/`. Файл состояния содержит PID и время старта обоих `fcntl.flock` в служебном каталоге `~/.local/state/xray-proxy/`. Второй `start`
процессов, путь к бинарнику и порт, но не VLESS-ссылку и не её секреты. `status` отказывает до запуска процессов и изменения state или сгенерированного
и `stop` сверяют PID со временем старта и конфига. `state.json` нужен для восстановления после сбоя, но не служит
ожидаемым исполняемым файлом, поэтому повторное использование PID не считается блокировкой.
управляемым процессом. `stop` посылает SIGTERM супервизору, а тот завершает Xray
и очищает состояние. Если супервизор аварийно исчез, `stop` может завершить Файл состояния содержит ровно `supervisor_pid`, `xray_pid`, `xray_path`,
только дочерний Xray, идентичность которого подтверждена теми же данными. `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` У пользователя может быть только один управляемый экземпляр. Повторный `start`
не заменяет его другим профилем. Для смены профиля нужны явные `stop` и `start`. не заменяет его другим профилем. Для смены профиля нужны явные `stop` и `start`.
+84
View File
@@ -1,10 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os import os
import runpy
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest import mock
from pathlib import Path from pathlib import Path
@@ -154,6 +156,88 @@ class XrayProxyCliTest(unittest.TestCase):
self.assertIn("требует Python 3.8", result.stderr) self.assertIn("требует Python 3.8", result.stderr)
self.assertIn("Python 3.7", 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): def test_broken_config_symlink_is_not_treated_as_missing_config(self):
config_dir = self.config_home / "proxy" config_dir = self.config_home / "proxy"
config_dir.mkdir() config_dir.mkdir()
+324 -11
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import fcntl
import json import json
import os import os
import shutil
import signal import signal
import socket import socket
import subprocess import subprocess
@@ -22,8 +24,7 @@ PCS_A = "a" * 64
PCS_B = "b" * 64 PCS_B = "b" * 64
FAKE_XRAY = r'''#!/usr/bin/env python3 FAKE_XRAY_OPERAND = r'''import json
import json
import os import os
import re import re
import signal import signal
@@ -32,21 +33,22 @@ import sys
import threading import threading
log_path = os.environ["FAKE_XRAY_LOG"] log_path = os.environ["FAKE_XRAY_LOG"]
operation = os.path.basename(sys.argv[0])
def record(kind, **values): def record(kind, **values):
values["kind"] = kind values["kind"] = kind
values["argv"] = sys.argv[1:] values["argv"] = [operation] + sys.argv[1:]
values["executable"] = os.path.realpath(sys.argv[0]) values["executable"] = os.path.realpath("/proc/self/exe")
with open(log_path, "a", encoding="utf-8") as stream: with open(log_path, "a", encoding="utf-8") as stream:
stream.write(json.dumps(values, sort_keys=True) + "\n") stream.write(json.dumps(values, sort_keys=True) + "\n")
if sys.argv[1:] == ["version"]: if operation == "version" and not sys.argv[1:]:
record("version") record("version")
print(os.environ["FAKE_XRAY_VERSION"]) print(os.environ["FAKE_XRAY_VERSION"])
raise SystemExit(int(os.environ.get("FAKE_VERSION_EXIT", "0"))) 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_test = operation == "run" and len(sys.argv) == 4 and sys.argv[1:3] == ["-test", "-c"]
is_run = len(sys.argv) == 4 and sys.argv[1:3] == ["run", "-c"] is_run = operation == "run" and len(sys.argv) == 3 and sys.argv[1] == "-c"
if not (is_test or is_run): if not (is_test or is_run):
record("bad-argv") record("bad-argv")
raise SystemExit(64) raise SystemExit(64)
@@ -73,7 +75,7 @@ if os.environ["FAKE_XRAY_VERSION"].startswith("Xray 26.3.27"):
): ):
raise SystemExit(65) raise SystemExit(65)
if sys.argv[2] == "-test": if is_test:
record("test", config=config) record("test", config=config)
if os.environ.get("FAKE_TEST_FAIL") == "1": if os.environ.get("FAKE_TEST_FAIL") == "1":
sys.stderr.write(os.environ.get("FAKE_TEST_SECRET", "test failed")) sys.stderr.write(os.environ.get("FAKE_TEST_SECRET", "test failed"))
@@ -92,6 +94,8 @@ stopping = False
def stop(signum, _frame): def stop(signum, _frame):
global stopping global stopping
record("signal", signum=signum) record("signal", signum=signum)
if signum == signal.SIGTERM and os.environ.get("FAKE_IGNORE_TERM") == "1":
return
stopping = True stopping = True
signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGTERM, stop)
@@ -162,8 +166,10 @@ class ManagedXrayCliTest(unittest.TestCase):
self.bin_dir.mkdir() self.bin_dir.mkdir()
self.log = self.root / "xray.log" self.log = self.root / "xray.log"
self.xray = self.bin_dir / "xray" 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) 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 = os.environ.copy()
self.environment.update( self.environment.update(
{ {
@@ -238,6 +244,7 @@ class ManagedXrayCliTest(unittest.TestCase):
env.update(environment) env.update(environment)
return subprocess.run( return subprocess.run(
[str(CLI)] + list(args), [str(CLI)] + list(args),
cwd=str(self.root),
text=True, text=True,
capture_output=True, capture_output=True,
env=env, env=env,
@@ -264,12 +271,25 @@ class ManagedXrayCliTest(unittest.TestCase):
return [] return []
return [json.loads(line) for line in self.log.read_text().splitlines()] 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): def start(self, profile=None):
args = [str(CLI), "start"] args = [str(CLI), "start"]
if profile is not None: if profile is not None:
args.extend(["-f", str(profile)]) args.extend(["-f", str(profile)])
process = subprocess.Popen( process = subprocess.Popen(
args, args,
cwd=str(self.root),
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
@@ -373,10 +393,13 @@ class ManagedXrayCliTest(unittest.TestCase):
process = self.start() process = self.start()
state_path = self.state_home / "xray-proxy/state.json" state_path = self.state_home / "xray-proxy/state.json"
config_path = self.state_home / "xray-proxy/client.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")) state = json.loads(state_path.read_text(encoding="utf-8"))
config = json.loads(config_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, state_path.stat().st_mode & 0o777)
self.assertEqual(0o600, config_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(0o700, state_path.parent.stat().st_mode & 0o777)
self.assertEqual(str(self.xray.resolve()), state["xray_path"]) self.assertEqual(str(self.xray.resolve()), state["xray_path"])
self.assertEqual("127.0.0.1", state["address"]) self.assertEqual("127.0.0.1", state["address"])
@@ -389,9 +412,20 @@ class ManagedXrayCliTest(unittest.TestCase):
"config_path", "config_path",
"address", "address",
"port", "port",
"supervisor_path",
"supervisor_starttime",
"xray_starttime",
"boot_id",
}, },
set(state), 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") state_text = state_path.read_text(encoding="utf-8")
self.assert_no_secrets(state_text, profile_text) self.assert_no_secrets(state_text, profile_text)
stream = config["outbounds"][0]["streamSettings"] stream = config["outbounds"][0]["streamSettings"]
@@ -415,6 +449,283 @@ class ManagedXrayCliTest(unittest.TestCase):
argv_text = "\0".join(entry["argv"]) argv_text = "\0".join(entry["argv"])
self.assert_no_secrets(argv_text, profile_text) 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): def test_versions_and_profile_failures_stop_before_test_or_run_without_leaks(self):
invalid_versions = ( invalid_versions = (
"Xray 25.12.8", "Xray 25.12.8",
@@ -556,6 +867,7 @@ class ManagedXrayCliTest(unittest.TestCase):
config.write_text('{"value":' + constant + '}\n', encoding="utf-8") config.write_text('{"value":' + constant + '}\n', encoding="utf-8")
result = subprocess.run( result = subprocess.run(
[str(self.xray), "run", "-test", "-c", str(config)], [str(self.xray), "run", "-test", "-c", str(config)],
cwd=str(self.root),
text=True, text=True,
capture_output=True, capture_output=True,
env=self.environment, env=self.environment,
@@ -576,6 +888,7 @@ class ManagedXrayCliTest(unittest.TestCase):
self.write_machine_config("xray") self.write_machine_config("xray")
process = subprocess.Popen( process = subprocess.Popen(
[str(CLI), "start"], [str(CLI), "start"],
cwd=str(self.root),
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
@@ -744,8 +1057,8 @@ class ManagedXrayCliTest(unittest.TestCase):
proxy_bin.mkdir() proxy_bin.mkdir()
(proxy_bin / "xray-proxy").symlink_to(CLI) (proxy_bin / "xray-proxy").symlink_to(CLI)
invocations = ( invocations = (
([str(CLI)], REPO_ROOT, self.environment), ([str(CLI)], self.root, self.environment),
([os.path.relpath(str(CLI), str(REPO_ROOT))], REPO_ROOT, self.environment), ([os.path.relpath(str(CLI), str(self.root))], self.root, self.environment),
( (
["xray-proxy"], ["xray-proxy"],
self.root, self.root,
+305 -46
View File
@@ -11,6 +11,7 @@ if sys.version_info < (3, 8):
import argparse import argparse
import configparser import configparser
import fcntl
import http.client import http.client
import ipaddress import ipaddress
import json import json
@@ -63,6 +64,10 @@ STATE_KEYS = {
"config_path", "config_path",
"address", "address",
"port", "port",
"supervisor_path",
"supervisor_starttime",
"xray_starttime",
"boot_id",
} }
@@ -126,6 +131,10 @@ def generated_config_path():
return state_home() / "client.json" return state_home() / "client.json"
def lock_path():
return state_home() / "lock"
def validate_address(value): def validate_address(value):
try: try:
parsed = ipaddress.ip_address(value) parsed = ipaddress.ip_address(value)
@@ -588,10 +597,54 @@ def run_xray_test(path, config_path):
def process_alive(pid): def process_alive(pid):
try: try:
os.kill(pid, 0) Path("/proc/{}".format(pid)).stat()
return True return True
except OSError: except FileNotFoundError:
return False 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): 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] 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): def process_argument_resolves_to(pid, arguments, expected_path):
try: try:
process_cwd = Path("/proc/{}/cwd".format(pid)).resolve(strict=True) process_cwd = Path("/proc/{}/cwd".format(pid)).resolve(strict=True)
@@ -648,34 +691,134 @@ def read_managed_state():
raise ProxyError("состояние управляемого Xray не подтверждено") raise ProxyError("состояние управляемого Xray не подтверждено")
if not isinstance(value, dict) or set(value) != STATE_KEYS: if not isinstance(value, dict) or set(value) != STATE_KEYS:
raise ProxyError("состояние управляемого Xray не подтверждено") 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 не подтверждено") 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 не подтверждено") raise ProxyError("состояние управляемого Xray не подтверждено")
return value 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): def verify_managed_state(config=None, require_port=True):
state = read_managed_state() state = read_managed_state()
supervisor = state["supervisor_pid"] outcome = managed_state_outcome(state)
child = state["xray_pid"] if outcome == "stale":
if supervisor <= 1 or child <= 1 or supervisor == child: lock = acquire_start_lock()
raise ProxyError("личность управляемого Xray не подтверждена") try:
supervisor_args = process_cmdline(supervisor) state = read_managed_state()
child_args = process_cmdline(child) outcome = managed_state_outcome(state)
cli = Path(__file__).resolve() if outcome == "stale":
if ( cleanup_stale_files()
not process_alive(supervisor) raise ProxyError("устаревшее состояние управляемого Xray очищено")
or not process_argument_resolves_to(supervisor, supervisor_args, cli) finally:
or "start" not in supervisor_args lock.close()
or not process_alive(child) if outcome == "orphan":
or state["xray_path"] not in child_args raise ProxyError("обнаружен Xray-сирота; выполните xray-proxy stop")
or "run" not in child_args if outcome != "healthy":
or "-c" not in child_args raise unverifiable_state_error()
or state["config_path"] not in child_args
or process_parent(child) != supervisor
):
raise ProxyError("личность процесса управляемого Xray не подтверждена")
if config is not None and ( if config is not None and (
config.proxy_type != "xray" config.proxy_type != "xray"
or config.address != state["address"] or config.address != state["address"]
@@ -687,6 +830,26 @@ def verify_managed_state(config=None, require_port=True):
return state 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): def safe_summary(profile, local_port):
query = profile.query query = profile.query
print( print(
@@ -823,8 +986,39 @@ def command_start(config, profile_file):
raise ProxyError("для type=xray поле address должно быть loopback IP-адресом") raise ProxyError("для type=xray поле address должно быть loopback IP-адресом")
if not address.is_loopback: if not address.is_loopback:
raise ProxyError("для type=xray требуется loopback IP-адрес") 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(): 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" profile_path = Path(profile_file) if profile_file else xray_config_home() / "default.url"
raw_profile = read_profile(profile_path) raw_profile = read_profile(profile_path)
@@ -847,9 +1041,15 @@ def command_start(config, profile_file):
previous = {} previous = {}
for handled in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): for handled in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
previous[handled] = signal.signal(handled, request_stop) 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( child = subprocess.Popen(
[executable, "run", "-c", str(generated_config_path())] [executable, "run", "-c", str(generated_config_path())]
) )
xray_starttime = wait_for_child_starttime(child)
state = { state = {
"supervisor_pid": os.getpid(), "supervisor_pid": os.getpid(),
"xray_pid": child.pid, "xray_pid": child.pid,
@@ -857,6 +1057,10 @@ def command_start(config, profile_file):
"config_path": str(generated_config_path()), "config_path": str(generated_config_path()),
"address": config.address, "address": config.address,
"port": config.port, "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) write_private_json(state_path(), state)
deadline = time.monotonic() + max(1.0, preflight_timeout()) deadline = time.monotonic() + max(1.0, preflight_timeout())
@@ -872,16 +1076,24 @@ def command_start(config, profile_file):
last_error = error last_error = error
time.sleep(0.03) time.sleep(0.03)
if last_error is not None: if last_error is not None:
raise ProxyError("локальный порт дочернего Xray не стал доступен") raise ProxyError(
"локальный порт {} занят или недоступен".format(config.port)
)
diagnose_ip(config) diagnose_ip(config)
while child.poll() is None and stopping["signal"] is None: while child.poll() is None and stopping["signal"] is None:
time.sleep(0.05) time.sleep(0.05)
if stopping["signal"] is not None and child.poll() is None: if stopping["signal"] is not None and child.poll() is None:
os.kill(child.pid, stopping["signal"]) if not signal_xray(state, stopping["signal"]):
if child.poll() is None:
raise unverifiable_state_error()
else:
try: try:
child.wait(timeout=stop_timeout()) child.wait(timeout=stop_timeout())
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
child.kill() if not signal_xray(state, signal.SIGKILL):
if child.poll() is None:
raise unverifiable_state_error()
else:
child.wait() child.wait()
return_code = child.wait() return_code = child.wait()
if stopping["signal"] is not None: if stopping["signal"] is not None:
@@ -893,13 +1105,22 @@ def command_start(config, profile_file):
if "previous" in locals(): if "previous" in locals():
for handled, handler in previous.items(): for handled, handler in previous.items():
signal.signal(handled, handler) signal.signal(handled, handler)
if "child" in locals() and child.poll() is None: preserve_managed_files = False
child.terminate() if "child" in locals() and child.poll() is None and "state" in locals():
if signal_xray(state, signal.SIGTERM):
try: try:
child.wait(timeout=stop_timeout()) child.wait(timeout=stop_timeout())
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
child.kill() if signal_xray(state, signal.SIGKILL):
child.wait() 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()) cleanup_managed_files(os.getpid())
@@ -914,12 +1135,35 @@ def stop_timeout():
return value return value
def command_stop(): def stop_managed_state(timeout, state, outcome):
timeout = stop_timeout() if outcome == "stale":
state = verify_managed_state(require_port=True) cleanup_stale_files()
try: raise ProxyError("устаревшее состояние управляемого Xray очищено")
os.kill(state["supervisor_pid"], signal.SIGTERM) if outcome == "unverifiable":
except OSError: 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("не удалось послать сигнал подтверждённому супервизору") raise ProxyError("не удалось послать сигнал подтверждённому супервизору")
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
@@ -930,6 +1174,21 @@ def command_stop():
raise ProxyError("штатная остановка управляемого Xray превысила таймаут") 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(): def build_parser():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="xray-proxy", prog="xray-proxy",