zapret-kvn/xray_fluent/application/xray_runtime_service.py
loop-uh d5a4715771
Some checks failed
Windows project source guards / test (push) Has been cancelled
feat: use official Amnezia transport and organize runtime modules
2026-09-06 00:14:08 +03:00

323 lines
13 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.

from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from typing import TYPE_CHECKING, Any
from ..constants import PROXY_HOST, XRAY_TUN_DEFAULT_INTERFACE_NAME
from ..engines.xray import get_windows_default_route_context
from .connection_service import find_free_api_port
from .outbound_pool_service import ensure_xray_pool_control_plane
from .port_allocator import apply_proxy_port_auto_selection
from .runtime_introspection import extract_xray_runtime_ports
from .runtime_security import strip_xray_proxy_inbounds
from .session_state import XrayRuntimeConfig
if TYPE_CHECKING:
from .controller import AppController
from ..profiles.models import Node
APP_METRICS_API_TAG = "__app_metrics_api"
APP_METRICS_API_INBOUND_TAG = "__app_metrics_api_in"
APP_TUN_INBOUND_TAG = "__app_tun_in"
def inspect_active_xray_config(controller: AppController) -> tuple:
path, text = controller.load_active_xray_config_text()
text_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
has_proxy_outbound = False
socks_port = 0
http_port = 0
api_port = 0
try:
payload = json.loads(text)
except json.JSONDecodeError:
payload = None
if payload is not None:
ensure_xray_metrics_contract(controller, payload, allocate_port=False)
has_proxy_outbound = controller._config_has_proxy_outbound(payload)
socks_port, http_port, api_port = extract_xray_runtime_ports(payload)
return path, text_hash, has_proxy_outbound, socks_port, http_port, api_port
def ensure_xray_metrics_contract(
controller: AppController,
payload: dict[str, Any],
*,
allocate_port: bool,
) -> tuple[int, tuple[str, ...]]:
stats = payload.get("stats")
if not isinstance(stats, dict):
payload["stats"] = {}
policy = controller._ensure_dict(payload, "policy")
system_policy = controller._ensure_dict(policy, "system")
system_policy["statsInboundUplink"] = True
system_policy["statsInboundDownlink"] = True
system_policy["statsOutboundUplink"] = True
system_policy["statsOutboundDownlink"] = True
outbounds = controller._ensure_list(payload, "outbounds")
api = controller._ensure_dict(payload, "api")
existing_api_tag = str(api.get("tag") or "").strip()
api_tag = APP_METRICS_API_TAG
if existing_api_tag:
for outbound in outbounds:
if not isinstance(outbound, dict):
continue
if str(outbound.get("tag") or "").strip() != existing_api_tag:
continue
protocol = str(outbound.get("protocol") or "").strip().lower()
if protocol in {"freedom", "loopback"}:
api_tag = existing_api_tag
break
api["tag"] = api_tag
services = api.get("services")
normalized_services = [str(item) for item in services] if isinstance(services, list) else []
if "StatsService" not in normalized_services:
normalized_services.append("StatsService")
api["services"] = normalized_services
inbounds = controller._ensure_list(payload, "inbounds")
existing_ports = controller._collect_xray_inbound_ports(payload)
preferred_api_port = 0
for inbound in inbounds:
if not isinstance(inbound, dict):
continue
if str(inbound.get("tag") or "") != APP_METRICS_API_INBOUND_TAG:
continue
try:
preferred_api_port = int(inbound.get("port") or 0)
except (TypeError, ValueError):
preferred_api_port = 0
if preferred_api_port > 0:
existing_ports.discard(preferred_api_port)
break
if preferred_api_port > 0:
api_port = preferred_api_port
elif allocate_port:
try:
api_port = find_free_api_port(excluded=existing_ports)
except RuntimeError as exc:
raise ValueError("Не удалось выделить локальный порт для Xray metrics API.") from exc
else:
api_port = 0
metrics_inbound = {
"tag": APP_METRICS_API_INBOUND_TAG,
"listen": PROXY_HOST,
"port": api_port,
"protocol": "dokodemo-door",
"settings": {"address": PROXY_HOST},
}
controller._replace_or_append_tagged(inbounds, APP_METRICS_API_INBOUND_TAG, metrics_inbound)
has_api_outbound = any(
isinstance(outbound, dict) and str(outbound.get("tag") or "") == api_tag
for outbound in outbounds
)
if not has_api_outbound:
outbounds.append({"tag": api_tag, "protocol": "freedom", "settings": {}})
user_inbound_tags: list[str] = []
for index, inbound in enumerate(inbounds):
if not isinstance(inbound, dict):
continue
tag = str(inbound.get("tag") or "").strip()
if tag == APP_METRICS_API_INBOUND_TAG:
continue
if not tag:
tag = f"__app_user_inbound_{index}"
inbound["tag"] = tag
if tag not in user_inbound_tags:
user_inbound_tags.append(tag)
routing = controller._ensure_dict(payload, "routing")
rules = controller._ensure_list(routing, "rules")
metrics_rule = {
"type": "field",
"inboundTag": [APP_METRICS_API_INBOUND_TAG],
"outboundTag": api_tag,
}
replaced = False
for index, rule in enumerate(rules):
if not isinstance(rule, dict):
continue
inbound_tags = rule.get("inboundTag")
if isinstance(inbound_tags, list) and APP_METRICS_API_INBOUND_TAG in [str(item) for item in inbound_tags]:
rules[index] = metrics_rule
replaced = True
break
if not replaced:
rules.insert(0, metrics_rule)
return api_port, tuple(user_inbound_tags)
def ensure_xray_tun_contract(controller: AppController, payload: dict[str, Any]) -> str:
inbounds = controller._ensure_list(payload, "inbounds")
for inbound in inbounds:
if not isinstance(inbound, dict):
continue
if str(inbound.get("protocol") or "").strip().lower() != "tun":
continue
settings = controller._ensure_dict(inbound, "settings")
return str(settings.get("name") or "").strip() or XRAY_TUN_DEFAULT_INTERFACE_NAME
inbounds.append(
{
"tag": APP_TUN_INBOUND_TAG,
"protocol": "tun",
"settings": {},
"sniffing": {
"enabled": True,
"destOverride": ["http", "tls", "quic"],
"routeOnly": True,
},
}
)
return XRAY_TUN_DEFAULT_INTERFACE_NAME
def xray_outbound_is_loop_protected(outbound: dict[str, Any]) -> bool:
send_through = str(outbound.get("sendThrough") or "").strip()
if send_through and send_through not in {"0.0.0.0", "::"}:
return True
stream_settings = outbound.get("streamSettings")
if not isinstance(stream_settings, dict):
return False
sockopt = stream_settings.get("sockopt")
if not isinstance(sockopt, dict):
return False
return bool(str(sockopt.get("interface") or "").strip())
def apply_xray_tun_loop_prevention(controller: AppController, payload: dict[str, Any], interface_alias: str) -> int:
patched = 0
outbounds = controller._ensure_list(payload, "outbounds")
for outbound in outbounds:
if not isinstance(outbound, dict):
continue
tag = str(outbound.get("tag") or "").strip()
protocol = str(outbound.get("protocol") or "").strip().lower()
if tag in {APP_METRICS_API_TAG, "api"} or protocol in {"blackhole", "loopback", "dns"}:
continue
if xray_outbound_is_loop_protected(outbound):
continue
stream_settings = controller._ensure_dict(outbound, "streamSettings")
sockopt = controller._ensure_dict(stream_settings, "sockopt")
sockopt["interface"] = interface_alias
patched += 1
return patched
def build_runtime_xray_config(controller: AppController, node: Node | None = None, *, tun_mode: bool = False) -> XrayRuntimeConfig:
source_path, text = controller.load_active_xray_config_text()
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"{source_path.name}: {controller._format_json_error_message(text, exc)}") from exc
if not isinstance(payload, dict):
raise ValueError("Корень xray config должен быть JSON-объектом.")
tun_interface_name = ""
if tun_mode:
tun_interface_name = controller._ensure_xray_tun_contract(payload)
strip_xray_proxy_inbounds(payload)
api_port, inbound_tags = controller._ensure_xray_metrics_contract(payload, allocate_port=True)
allowed_proxy_ports: set[int] = set()
active_session = getattr(controller, "_active_session", None)
if controller.xray.is_running and active_session is not None:
if active_session.socks_port > 0:
allowed_proxy_ports.add(int(active_session.socks_port))
if active_session.http_port > 0:
allowed_proxy_ports.add(int(active_session.http_port))
try:
port_selection = apply_proxy_port_auto_selection(payload, allowed_ports=allowed_proxy_ports)
except RuntimeError as exc:
raise ValueError("Не удалось подобрать свободные локальные SOCKS/HTTP порты для Xray.") from exc
outbounds = payload.get("outbounds")
has_proxy_outbound = False
used_selected_node = False
outbound_pool_tags: dict[str, str] = {}
if isinstance(outbounds, list):
for index, outbound in enumerate(outbounds):
if not isinstance(outbound, dict) or outbound.get("tag") != "proxy":
continue
has_proxy_outbound = True
if node is None:
raise ValueError("В конфиге есть outbound tag `proxy`. Выберите сервер для запуска xray.")
problem = controller._prepare_node_for_runtime(node)
if problem:
raise ValueError(problem)
# Единый источник пула (П1/П3): кэшированный пул контроллера.
pool = controller.xray_outbound_pool()
if pool.contains(node.id):
outbounds[index:index + 1] = pool.outbounds()
ensure_xray_pool_control_plane(payload, pool)
outbound_pool_tags = dict(pool.tags)
else:
proxy_outbound = deepcopy(node.outbound)
proxy_outbound["tag"] = "proxy"
outbounds[index] = proxy_outbound
used_selected_node = True
break
loop_prevention_interface = ""
loop_prevention_patched_outbounds = 0
if tun_mode:
needs_loop_patch = False
if isinstance(outbounds, list):
for outbound in outbounds:
if not isinstance(outbound, dict):
continue
tag = str(outbound.get("tag") or "").strip()
protocol = str(outbound.get("protocol") or "").strip().lower()
if tag in {APP_METRICS_API_TAG, "api"} or protocol in {"blackhole", "loopback", "dns"}:
continue
if not controller._xray_outbound_is_loop_protected(outbound):
needs_loop_patch = True
break
if needs_loop_patch:
context = get_windows_default_route_context()
if context is None:
raise ValueError(
"Не удалось определить активный сетевой интерфейс для xray TUN loop prevention. "
"Либо укажите streamSettings.sockopt.interface/sendThrough в raw xray config, "
"либо используйте sing-box TUN."
)
loop_prevention_interface = context.interface_alias
loop_prevention_patched_outbounds = controller._apply_xray_tun_loop_prevention(payload, loop_prevention_interface)
socks_port, http_port, _ = extract_xray_runtime_ports(payload)
requested_socks_port = socks_port
requested_http_port = http_port
if port_selection is not None:
requested_socks_port = port_selection.requested_socks_port
requested_http_port = port_selection.requested_http_port
ping_host, ping_port = controller._infer_xray_ping_target(payload, node if used_selected_node else None)
return XrayRuntimeConfig(
config=payload,
source_path=source_path,
has_proxy_outbound=has_proxy_outbound,
used_selected_node=used_selected_node,
requested_socks_port=requested_socks_port,
requested_http_port=requested_http_port,
socks_port=socks_port,
http_port=http_port,
api_port=api_port,
tun_interface_name=tun_interface_name,
loop_prevention_interface=loop_prevention_interface,
loop_prevention_patched_outbounds=loop_prevention_patched_outbounds,
inbound_tags=inbound_tags,
ping_host=ping_host,
ping_port=ping_port,
outbound_pool_tags=outbound_pool_tags,
)