zapret-kvn/xray_fluent/engines/singbox/operations.py

395 lines
17 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
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from .runtime_planner import SingboxRuntimePlan
from ..hysteria.runtime_contract import SECURITY_FAILURES, HysteriaFailureCode, HysteriaRuntimeState
if TYPE_CHECKING:
from ...application.controller import AppController
from ...profiles.models import Node
@dataclass(slots=True)
class SingboxStartResult:
plan: SingboxRuntimePlan
session_label: str
def _proxy_session_label(plan: SingboxRuntimePlan, node: Node | None) -> str:
if plan.used_selected_node and node is not None:
return f"{plan.source_path.name} / {node.name}"
return plan.source_path.name
def _runtime_suffix(plan: SingboxRuntimePlan) -> str:
if plan.is_hybrid:
return " (sing-box + Xray sidecar)"
if plan.is_hysteria_sidecar:
return " (sing-box + Hysteria2)"
if getattr(plan, "amnezia_sidecar", None) is not None:
return " (sing-box + Amnezia WG/AWG)"
return " (sing-box extended)"
def _notify_proxy_port_change(controller: AppController, plan: SingboxRuntimePlan) -> None:
if not plan.proxy_ports_changed:
return
message = (
"Локальные порты sing-box изменены: "
f"SOCKS {plan.requested_socks_port} -> {plan.socks_port}, "
f"HTTP {plan.requested_http_port} -> {plan.http_port}. "
"Исходные порты заняты или зарезервированы Windows."
)
controller._log(f"[sing-box] {message}")
controller.status.emit("warning-long", message)
def _apply_system_proxy(controller: AppController, plan: SingboxRuntimePlan) -> bool:
if controller.state.settings.enable_system_proxy:
try:
controller.proxy.enable(
plan.http_port,
plan.socks_port,
bypass_lan=controller._system_proxy_bypass_lan(),
)
except Exception as exc:
controller._set_connection_status(
"error",
f"Не удалось включить системный прокси: {exc}",
level="error",
)
return False
else:
controller.proxy.disable(restore_previous=True)
return True
def start_proxy(
controller: AppController,
node: Node | None,
*,
prev_active_core: str,
) -> SingboxStartResult | None:
controller._active_core = "singbox"
try:
plan = controller._plan_proxy_runtime_singbox(node)
except ValueError as exc:
controller._active_core = prev_active_core
controller._set_connection_status("error", str(exc), level="error")
return None
session_label = _proxy_session_label(plan, node)
suffix = _runtime_suffix(plan)
controller._set_connection_status("starting", f"Запуск прокси: {session_label}{suffix}...", level="info")
_notify_proxy_port_change(controller, plan)
controller._log(f"[proxy] sing-box planner outcome: {plan.outcome} from {plan.source_path}")
controller._xray_api_port = 0
if not controller._start_singbox_runtime_plan(plan):
controller._set_connection_status(
"error",
"Не удалось запустить sing-box proxy runtime. Смотрите причину в последних строках лога sing-box.",
level="error",
)
controller._active_core = prev_active_core
return None
if not _apply_system_proxy(controller, plan):
controller.singbox.stop()
if getattr(controller, "amnezia", None) is not None:
controller.amnezia.stop()
if controller.xray.is_running:
controller.xray.stop()
if controller.hysteria.is_running:
controller.hysteria.stop()
controller._protect_ss_port = 0
controller._protect_ss_password = ""
controller._active_core = prev_active_core
return None
return SingboxStartResult(plan=plan, session_label=session_label)
def start_tun(
controller: AppController,
node: Node | None,
*,
prev_active_core: str,
) -> SingboxStartResult | None:
controller._active_core = "singbox"
try:
plan = controller._plan_runtime_singbox(node)
except ValueError as exc:
controller._active_core = prev_active_core
controller._set_connection_status("error", str(exc), level="error")
return None
session_label = plan.source_path.name
if plan.used_selected_node and node is not None:
session_label = f"{plan.source_path.name} / {node.name}"
start_message = f"Запуск VPN: {session_label}{_runtime_suffix(plan)}..."
controller._set_connection_status("starting", start_message, level="info")
controller._log(f"[tun] sing-box planner outcome: {plan.outcome} from {plan.source_path}")
if plan.used_selected_node and node is not None:
if plan.is_hybrid:
controller._log(
f"[tun] outbound tag 'proxy' replaced with local xray relay for unsupported node: {node.name}"
)
elif plan.is_hysteria_sidecar:
controller._log(
f"[tun] outbound tag 'proxy' replaced with local official Hysteria2 relay: {node.name}"
)
else:
controller._log(f"[tun] outbound tag 'proxy' replaced from selected node: {node.name}")
if not controller._start_singbox_runtime_plan(plan):
controller._set_connection_status(
"error",
(
"Не удалось запустить sing-box sidecar runtime. Смотрите причину в последних строках лога."
if plan.sidecar_kind
else "Не удалось запустить sing-box TUN runtime. Смотрите причину в последних строках лога sing-box."
),
level="error",
)
controller._active_core = prev_active_core
return None
return SingboxStartResult(plan=plan, session_label=session_label)
def _abort_security_recovery(controller: AppController, recovery: bool, replacement=None) -> bool:
if not recovery or getattr(controller, "_hysteria_last_failure_code", None) not in SECURITY_FAILURES:
return False
# QProcess events are pumped while replacement/HTTP readiness is pending.
# A security escalation from the still-owned old target forbids commit or
# rollback, even when the initial timeout already requested a replacement.
controller._desired_connected = False
if replacement is not None:
replacement.stop(expected=True)
controller._clear_pending_transport_selection()
controller._handle_unexpected_disconnect()
return True
def _abort_superseded_transition(
controller: AppController, generation: int, replacement=None, *, old_plan=None, front_changed=False,
) -> bool:
if controller._transition_generation == generation and controller._desired_connected:
return False
# Readiness and process stop pump Qt events. A newer selection/disconnect
# invalidates this candidate before it can publish or persist a session.
if replacement is not None:
replacement.stop(expected=True)
if front_changed:
if not controller._desired_connected or not controller._rollback_singbox_front(old_plan):
controller._handle_unexpected_disconnect()
controller._log("[transport-transition] superseded candidate discarded")
return True
def capture_runtime_session(controller: AppController, plan: SingboxRuntimePlan, node: Node | None, *, tun: bool) -> None:
session_node = node if plan.used_selected_node else None
if session_node is not None:
session_node.last_used_at = datetime.now(timezone.utc).isoformat()
ping_host, ping_port = controller._infer_singbox_ping_target(plan.singbox_config, session_node)
controller._capture_active_session(
session_node,
tun=tun,
core=controller._active_core,
api_port=controller._xray_api_port,
hybrid=plan.is_hybrid,
sidecar_kind=plan.sidecar_kind,
socks_port=plan.socks_port or None,
http_port=plan.http_port or None,
xray_inbound_tags=(),
sidecar_relay_port=(
plan.xray_sidecar.relay_port
if plan.xray_sidecar
else plan.hysteria_sidecar.relay_port
if plan.hysteria_sidecar
else plan.amnezia_sidecar.relay_port
if getattr(plan, "amnezia_sidecar", None)
else 0
),
protect_ss_port=controller._protect_ss_port,
protect_ss_password=controller._protect_ss_password,
ping_host=ping_host,
ping_port=ping_port,
outbound_pool_tags=plan.selector_tags,
hybrid_relay_selector_tags=plan.hybrid_relay_selector_tags,
hybrid_relay_selected_tag=plan.hybrid_relay_selected_tag,
)
def restart_runtime(controller: AppController, reason: str) -> bool:
return _replace_runtime(controller, reason, tun=True)
def restart_proxy_runtime(controller: AppController, reason: str) -> bool:
return _replace_runtime(controller, reason, tun=False)
def _replace_runtime(controller: AppController, reason: str, *, tun: bool) -> bool:
node = controller._runtime_selected_node()
requested_generation = controller._transition_generation
hysteria_recovery = bool(controller._hysteria_recovery_active)
replacement_amnezia = None
amnezia_committed = False
controller._switching = True
try:
if _abort_security_recovery(controller, hysteria_recovery):
return False
controller._log(f"[transport-switch] {reason}")
try:
planner = controller._plan_runtime_singbox if tun else controller._plan_proxy_runtime_singbox
plan = planner(node, replacement=True)
except ValueError as exc:
controller._set_connection_status("error", str(exc), level="error")
return False
session_label = _proxy_session_label(plan, node)
controller._set_connection_status("starting", f"Переключение на {session_label}...", level="info")
if not tun:
_notify_proxy_port_change(controller, plan)
controller._stop_metrics_worker()
old_plan = getattr(controller, "_active_singbox_plan", None)
old_hysteria = controller.hysteria
old_amnezia = getattr(controller, "amnezia", None)
if getattr(plan, "amnezia_sidecar", None) is not None:
replacement_amnezia = controller._prepare_amnezia_replacement(plan)
if replacement_amnezia is None:
controller._set_connection_status("error", "Новый WG/AWG не прошёл проверку; прежний runtime сохранён.", level="error")
return False
replacement_hysteria = None
if plan.hysteria_sidecar is not None:
controller._hysteria_contract.advance(
HysteriaRuntimeState.PREPARING_REPLACEMENT,
generation=controller._hysteria_contract.session.session_generation,
)
replacement_hysteria = controller._prepare_hysteria_replacement(plan)
if _abort_security_recovery(controller, hysteria_recovery, replacement_hysteria):
return False
if replacement_hysteria is None:
controller._set_connection_status(
"error",
"Новый Hysteria sidecar не доказал readiness; прежний runtime сохранён.",
level="error",
)
if controller._hysteria_recovery_active:
controller._handle_unexpected_disconnect()
return False
if _abort_superseded_transition(controller, requested_generation, replacement_hysteria):
return False
if controller.singbox.is_running and not controller.singbox.stop():
if replacement_hysteria is not None:
replacement_hysteria.stop(expected=True)
controller._set_connection_status("error", "Не удалось остановить предыдущий процесс sing-box", level="error")
return False
if controller.xray.is_running and not controller.xray.stop():
if replacement_hysteria is not None:
replacement_hysteria.stop(expected=True)
controller._set_connection_status("error", "Не удалось остановить предыдущий процесс Xray sidecar", level="error")
return False
controller._xray_api_port = 0
controller._protect_ss_port = 0
controller._protect_ss_password = ""
if _abort_security_recovery(controller, hysteria_recovery, replacement_hysteria):
return False
if _abort_superseded_transition(controller, requested_generation, replacement_hysteria, old_plan=old_plan, front_changed=True):
return False
controller._hysteria_contract.advance(
HysteriaRuntimeState.COMMITTING_SWITCH,
generation=controller._hysteria_contract.session.session_generation,
)
front_ready = controller._start_singbox_runtime_plan(
plan,
prepared_hysteria=replacement_hysteria,
**({"prepared_amnezia": replacement_amnezia} if replacement_amnezia is not None else {}),
)
if _abort_security_recovery(controller, hysteria_recovery, replacement_hysteria):
return False
if _abort_superseded_transition(controller, requested_generation, replacement_hysteria, old_plan=old_plan, front_changed=True):
return False
if not front_ready:
rolled_back = controller._rollback_singbox_front(old_plan)
controller._hysteria_contract.terminal(
HysteriaFailureCode.LOCAL_FRONT_NOT_READY,
generation=controller._hysteria_contract.session.session_generation,
degraded=rolled_back,
)
controller._set_connection_status(
"error",
(
"Новый front не запустился; прежняя generation восстановлена."
if rolled_back
else "Новый front не запустился и rollback прежней generation не удался."
),
level="error",
)
if not rolled_back:
controller._handle_unexpected_disconnect()
return False
if replacement_hysteria is not None:
if not controller._commit_hysteria_replacement(replacement_hysteria):
controller._hysteria_last_failure_code = (
HysteriaFailureCode.TRANSITION_ROLLBACK_FAILED
)
controller._log(
"[hysteria-transition] new generation committed, but the old "
"process did not confirm shutdown"
)
elif old_hysteria.is_running:
old_hysteria.stop(expected=True)
controller._hysteria_contract.advance(
HysteriaRuntimeState.STOPPING_OLD,
generation=controller._hysteria_contract.session.session_generation,
)
if not tun and not _apply_system_proxy(controller, plan):
controller._handle_unexpected_disconnect()
return False
if _abort_superseded_transition(controller, requested_generation, replacement_hysteria, old_plan=old_plan, front_changed=True):
return False
session_node = node if plan.used_selected_node else None
capture_runtime_session(controller, plan, node, tun=tun)
if replacement_amnezia is not None:
controller.amnezia = replacement_amnezia
amnezia_committed = True
if old_amnezia is not None and (old_amnezia is not controller.amnezia or getattr(plan, "amnezia_sidecar", None) is None):
old_amnezia.stop()
if old_amnezia is not controller.amnezia:
old_amnezia.deleteLater()
if not controller._commit_pending_transport_selection(session_node):
controller._set_connection_status(
"error",
"Новый runtime готов, но selection commit отклонён.",
level="error",
)
controller._handle_unexpected_disconnect()
return False
if hysteria_recovery:
controller._record_hysteria_switch_commit()
suffix = _runtime_suffix(plan)
controller._set_connection_status("running", f"Переключено: {session_label}{suffix}", level="success")
controller.schedule_save()
return True
finally:
if replacement_amnezia is not None and not amnezia_committed:
replacement_amnezia.stop()
replacement_amnezia.deleteLater()
if controller._transition_generation == requested_generation and node is not None and getattr(controller, "_pending_transport_node_id", None) == node.id:
controller._pending_transport_node_id = None
controller._switching = False
controller._auto_switch_transitioning = False
controller._hysteria_recovery_active = False
_, controller.connected = controller._refresh_connected_state()
controller.connection_changed.emit(controller.connected)
if controller.connected:
controller._start_metrics_worker()
else:
controller._stop_metrics_worker()