zapret-kvn/xray_fluent/engines/xray_outbound_compat.py
loop-uh a4330f1e44 Xray: один несобираемый сервер больше не валит запуск всего пула
Диагностика 0.8.41: Xray 26.x отвергает kcpSettings с ключом header (даже {"type":"none"}),
а в одном конфиге пула лежат все ~650 серверов, поэтому один такой узел останавливал запуск
ядра для всех, а переключение откатывалось.

- engines/xray_outbound_compat: header none убирается без потерь, srtp/utp/… и seed — узел
  исключается из пула с причиной; парсер ссылок не пишет пустой header
- XrayManager.start_steps: если ядро назвало тег, на котором упал разбор конфига, outbound
  убирается и запуск повторяется (до 6 раз), ошибка показывается один раз и только если
  попытки кончились; проверено на настоящем Xray (0,41 с)
- контроллер: карантин отвергнутых серверов до замены их outbound, ошибка с именем, если
  отвергнут выбранный сервер
- предупреждения Xray «feature … is deprecated» не сбой: одна строка и счётчик повторов
- сводка [perf] перехода называет места, где GUI-поток занят дольше 100 мс
- пул целиком не урезается: замер показал ~30 мс разбора на 644 outbound и ломку ротации,
  умного/автосвитча и правила winws2 при урезании

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:06:09 +03:00

93 lines
4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Совместимость сохранённых outbound'ов с текущим ядром Xray.
Xray 26.x отвергает целиком любой конфиг, где в ``kcpSettings`` есть ключ
``header`` или ``seed`` (даже ``{"type": "none"}``): «The feature mkcp header &
seed has been removed». Узлы же хранятся в том виде, в каком их когда-то
импортировали, а весь пул из сотен узлов идёт в одном конфиге — одна такая
запись останавливала запуск ядра для всех.
Здесь только чистые функции без Qt и без доступа к состоянию приложения:
* :func:`normalize_xray_outbound` — без потерь убирает то, что ядро больше не
принимает, но ничего не значит (``header: none``);
* :func:`xray_outbound_incompatibility` — причина, по которой узел ядро не соберёт
и пересобрать без потери смысла нельзя (маскировка ``srtp``/``utp``/… и
``seed`` в новом ядре переносятся в ``finalmask`` не один в один).
"""
from __future__ import annotations
import re
from typing import Any
#: ``xray run`` называет несобираемый outbound по его тегу из конфига.
_FAILED_OUTBOUND_RE = re.compile(
r"failed to build outbound config with tag\s+(?P<tag>\S+)", re.IGNORECASE
)
_KCP_NETWORKS = {"kcp", "mkcp"}
_NO_HEADER_TYPES = {"", "none"}
def _kcp_settings(outbound: dict[str, Any]) -> dict[str, Any] | None:
stream = outbound.get("streamSettings")
if not isinstance(stream, dict):
return None
if str(stream.get("network") or "").strip().lower() not in _KCP_NETWORKS:
return None
settings = stream.get("kcpSettings")
return settings if isinstance(settings, dict) else None
def _kcp_header_type(settings: dict[str, Any]) -> str:
header = settings.get("header")
if isinstance(header, dict):
return str(header.get("type") or "").strip().lower()
return ""
def xray_outbound_incompatibility(outbound: object) -> str:
"""Пустая строка — узел собирается; иначе короткая причина для журнала и UI."""
if not isinstance(outbound, dict):
return ""
settings = _kcp_settings(outbound)
if settings is None:
return ""
if str(settings.get("seed") or "").strip():
return "mKCP seed удалён в Xray 26.x (нужен finalmask)"
header_type = _kcp_header_type(settings)
if header_type not in _NO_HEADER_TYPES:
return f"mKCP header «{header_type}» удалён в Xray 26.x (нужен finalmask)"
return ""
def normalize_xray_outbound(outbound: dict[str, Any]) -> dict[str, Any]:
"""Убрать из kcp-настроек пустые ``header``/``seed``. Меняет словарь на месте.
Вызывается на копии: сохранённый ``node.outbound`` остаётся как был.
"""
settings = _kcp_settings(outbound)
if settings is None:
return outbound
if "header" in settings and _kcp_header_type(settings) in _NO_HEADER_TYPES:
del settings["header"]
if "seed" in settings and not str(settings.get("seed") or "").strip():
del settings["seed"]
return outbound
def failed_outbound_tag(lines: object, *, prefix: str = "") -> str:
"""Тег outbound'а, на котором ядро остановило разбор конфига, из его вывода.
Берётся последнее совпадение: предупреждения идут раньше самой ошибки.
"""
found = ""
for line in lines or ():
match = _FAILED_OUTBOUND_RE.search(str(line))
if match and match.group("tag").startswith(prefix):
found = match.group("tag")
return found