zapret-kvn/xray_fluent/application/outbound_pool_service.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

152 lines
5.6 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.

"""App-owned transport pools used for zero-restart node switching.
The active raw JSON still owns DNS and routing. A pool only replaces the
transport behind the conventional ``proxy`` destination with a core-native
selector/balancer, so the data plane (inbounds, TUN and routes) can stay alive.
"""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
import hashlib
from typing import Iterable, Mapping
from ..profiles.models import Node
from ..engines.tls_fingerprint import apply_browser_fingerprint
from ..engines.xray_outbound_compat import normalize_xray_outbound, xray_outbound_incompatibility
from .protocol_core import ProtocolCore, protocol_core
XRAY_BALANCER_TAG = "__app_proxy_selector"
XRAY_OUTBOUND_PREFIX = "__app_proxy_"
SINGBOX_PROVIDER_TAG = "__app_nodes"
SINGBOX_SELECTOR_TAG = "proxy"
SINGBOX_OUTBOUND_PREFIX = "node_"
def _stable_suffix(node_id: str) -> str:
return hashlib.sha256(str(node_id).encode("utf-8")).hexdigest()[:20]
def xray_outbound_tag(node_id: str) -> str:
return f"{XRAY_OUTBOUND_PREFIX}{_stable_suffix(node_id)}"
def singbox_outbound_tag(node_id: str) -> str:
return f"{SINGBOX_OUTBOUND_PREFIX}{_stable_suffix(node_id)}"
def is_xray_pool_node(node: Node) -> bool:
return protocol_core(node) is ProtocolCore.XRAY
@dataclass(slots=True)
class XrayOutboundPool:
nodes: list[Node] = field(default_factory=list)
tags: dict[str, str] = field(default_factory=dict)
#: узлы Xray-ядра, которых нет в пуле, и почему: ядро их не соберёт, а один
#: такой outbound останавливал запуск всего пула.
excluded: dict[str, str] = field(default_factory=dict)
def contains(self, node_id: str | None) -> bool:
return str(node_id or "") in self.tags
def tag_for(self, node_id: str | None) -> str:
return self.tags.get(str(node_id or ""), "")
def outbounds(self) -> list[dict]:
result: list[dict] = []
for node in self.nodes:
outbound = apply_browser_fingerprint(normalize_xray_outbound(deepcopy(node.outbound)))
outbound["tag"] = self.tags[node.id]
result.append(outbound)
return result
def signature_payload(self) -> list[list[object]]:
return [[self.tags[node.id], node.outbound] for node in self.nodes]
def xray_pool_exclusion(node: Node, quarantine: Mapping[str, str] | None = None) -> str:
"""Причина, по которой Xray-узел не берётся в пул (пусто — берётся).
``quarantine`` — узлы, которые запущенное ядро уже отвергло (самовосстановление
запуска): они не возвращаются в пул, пока контроллер не снимет карантин.
"""
if quarantine and node.id in quarantine:
return quarantine[node.id] or "ядро Xray отвергло этот сервер"
return xray_outbound_incompatibility(node.outbound)
def build_xray_outbound_pool(
nodes: Iterable[Node],
*,
quarantine: Mapping[str, str] | None = None,
) -> XrayOutboundPool:
eligible: list[Node] = []
excluded: dict[str, str] = {}
for node in nodes:
if not is_xray_pool_node(node):
continue
reason = xray_pool_exclusion(node, quarantine)
if reason:
excluded[node.id] = reason
else:
eligible.append(node)
eligible.sort(key=lambda item: (item.sort_order, item.id))
return XrayOutboundPool(
nodes=eligible,
tags={node.id: xray_outbound_tag(node.id) for node in eligible},
excluded=excluded,
)
def route_xray_proxy_rules_to_balancer(rules: list[object]) -> int:
"""Retarget only explicit ``proxy`` rules; never invent routing policy."""
changed = 0
for rule in rules:
if not isinstance(rule, dict) or rule.get("outboundTag") != "proxy":
continue
rule.pop("outboundTag", None)
rule["balancerTag"] = XRAY_BALANCER_TAG
changed += 1
return changed
def ensure_xray_pool_control_plane(config: dict, pool: XrayOutboundPool) -> None:
routing = config.setdefault("routing", {})
if not isinstance(routing, dict):
raise ValueError("Секция routing Xray должна быть JSON-объектом.")
rules = routing.setdefault("rules", [])
if not isinstance(rules, list):
raise ValueError("Секция routing.rules Xray должна быть массивом.")
route_xray_proxy_rules_to_balancer(rules)
balancers = routing.setdefault("balancers", [])
if not isinstance(balancers, list):
raise ValueError("Секция routing.balancers Xray должна быть массивом.")
balancer = {
"tag": XRAY_BALANCER_TAG,
"selector": [XRAY_OUTBOUND_PREFIX],
# The controller pins the exact target immediately after startup.
"strategy": {"type": "random"},
}
for index, current in enumerate(balancers):
if isinstance(current, dict) and current.get("tag") == XRAY_BALANCER_TAG:
balancers[index] = balancer
break
else:
balancers.append(balancer)
api = config.setdefault("api", {})
if not isinstance(api, dict):
raise ValueError("Секция api Xray должна быть JSON-объектом.")
services = api.setdefault("services", [])
if not isinstance(services, list):
services = []
for service in ("StatsService", "RoutingService", "HandlerService"):
if service not in services:
services.append(service)
api["services"] = services