zapret-kvn/xray_fluent/engines/singbox/operations.py
loop-uh e93ee038f1
Some checks failed
Windows project source guards / test (push) Failing after 38s
Ссылки с allowInsecure=1 и отпечатком сертификата (pcs) работают
Xray-core 26.x отвергает `allowInsecure` («has been removed and migrated
to pinnedPeerCertSha256»), а импорт копировал его в tlsSettings и
выбрасывал `pcs`/`vcn` — VLESS-сервер с такой ссылкой не запускался.

- link_parser: `pcs`/`pinnedPeerCertSha256` → tlsSettings.pinnedPeerCertSha256
  (hex, двоеточия, список), `vcn` → verifyPeerCertByName для VLESS, Trojan
  и VMess (VMess теперь читает и allowInsecure). С пином allowInsecure не
  выдаётся: Xray сам не проверяет цепочку и сверяет отпечаток. Битый pcs —
  отказ на русском.
- VLESS без пина: allowInsecure не выдаётся, сертификат проверяется
  обычно (как на Android). Сохранённый узел без ссылки с allowInsecure —
  понятная ошибка вместо загадочной ошибки ядра.
- sing-box (Trojan/VMess): пин SHA-256 всего сертификата не переводится,
  insecure + предупреждение в журнале при запуске; тест скорости
  пропускает узлы, которые временный Xray не примет.
- tests/test_tls_cert_pin_links.py, docs/sing-box/runtime-config.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:58:54 +03:00

449 lines
20 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 .config_builder import unenforced_tls_pin_warning
from .runtime_planner import SingboxRuntimePlan
from ..hysteria.runtime_contract import SECURITY_FAILURES, HysteriaFailureCode, HysteriaRuntimeState
from ...application.async_steps import TransitionSteps, run_steps_blocking
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_steps(controller: AppController, plan: SingboxRuntimePlan) -> TransitionSteps:
# Реестр/WinINet — в отдельном FIFO-потоке прокси, не в GUI-потоке.
if controller.state.settings.enable_system_proxy:
try:
yield from controller._proxy_steps(
"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:
yield from controller._proxy_steps("disable", restore_previous=True)
return True
def start_proxy(
controller: AppController,
node: Node | None,
*,
prev_active_core: str,
) -> SingboxStartResult | None:
return run_steps_blocking(start_proxy_steps(controller, node, prev_active_core=prev_active_core))
def start_proxy_steps(
controller: AppController,
node: Node | None,
*,
prev_active_core: str,
) -> TransitionSteps:
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}")
_log_unenforced_tls_pin(controller, node)
controller._xray_api_port = 0
if not (yield from controller._start_singbox_runtime_plan_steps(plan)):
controller._set_connection_status(
"error",
"Не удалось запустить sing-box proxy runtime. Смотрите причину в последних строках лога sing-box.",
level="error",
)
controller._active_core = prev_active_core
return None
if not (yield from _apply_system_proxy_steps(controller, plan)):
yield from controller.singbox.stop_steps()
if getattr(controller, "amnezia", None) is not None:
yield from controller.amnezia.stop_steps()
if controller.xray.is_running:
yield from controller.xray.stop_steps()
if controller.hysteria.is_running:
yield from controller.hysteria.stop_steps()
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 _log_unenforced_tls_pin(controller: AppController, node: Node | None) -> None:
warning = unenforced_tls_pin_warning(node)
if warning:
controller._log(warning)
def start_tun(
controller: AppController,
node: Node | None,
*,
prev_active_core: str,
) -> SingboxStartResult | None:
return run_steps_blocking(start_tun_steps(controller, node, prev_active_core=prev_active_core))
def start_tun_steps(
controller: AppController,
node: Node | None,
*,
prev_active_core: str,
) -> TransitionSteps:
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}")
_log_unenforced_tls_pin(controller, node)
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 (yield from controller._start_singbox_runtime_plan_steps(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_steps(controller: AppController, recovery: bool, replacement=None) -> TransitionSteps:
if not recovery or getattr(controller, "_hysteria_last_failure_code", None) not in SECURITY_FAILURES:
return False
# Replacement/HTTP readiness steps let Qt signals run in between.
# 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:
yield from replacement.stop_steps(expected=True)
controller._clear_pending_transport_selection()
yield from controller._handle_unexpected_disconnect_steps()
return True
def _abort_superseded_transition_steps(
controller: AppController, generation: int, replacement=None, *, old_plan=None, front_changed=False,
) -> TransitionSteps:
if controller._transition_generation == generation and controller._desired_connected:
return False
# Readiness and process stop are asynchronous steps. A newer selection or
# disconnect invalidates this candidate before it can publish or persist a session.
if replacement is not None:
yield from replacement.stop_steps(expected=True)
if front_changed:
if not controller._desired_connected or not (yield from controller._rollback_singbox_front_steps(old_plan)):
yield from controller._handle_unexpected_disconnect_steps()
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 bool(run_steps_blocking(restart_runtime_steps(controller, reason)))
def restart_proxy_runtime(controller: AppController, reason: str) -> bool:
return bool(run_steps_blocking(restart_proxy_runtime_steps(controller, reason)))
def restart_runtime_steps(controller: AppController, reason: str) -> TransitionSteps:
return (yield from _replace_runtime_steps(controller, reason, tun=True))
def restart_proxy_runtime_steps(controller: AppController, reason: str) -> TransitionSteps:
return (yield from _replace_runtime_steps(controller, reason, tun=False))
def _replace_runtime_steps(controller: AppController, reason: str, *, tun: bool) -> TransitionSteps:
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 (yield from _abort_security_recovery_steps(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 = yield from controller._prepare_amnezia_replacement_steps(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 = yield from controller._prepare_hysteria_replacement_steps(plan)
if (yield from _abort_security_recovery_steps(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:
yield from controller._handle_unexpected_disconnect_steps()
return False
if (yield from _abort_superseded_transition_steps(controller, requested_generation, replacement_hysteria)):
return False
if _manager_active(controller.singbox) and not (yield from controller.singbox.stop_steps()):
if replacement_hysteria is not None:
yield from replacement_hysteria.stop_steps(expected=True)
controller._set_connection_status("error", "Не удалось остановить предыдущий процесс sing-box", level="error")
return False
if _manager_active(controller.xray) and not (yield from controller.xray.stop_steps()):
if replacement_hysteria is not None:
yield from replacement_hysteria.stop_steps(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 (yield from _abort_security_recovery_steps(controller, hysteria_recovery, replacement_hysteria)):
return False
if (yield from _abort_superseded_transition_steps(
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 = yield from controller._start_singbox_runtime_plan_steps(
plan,
prepared_hysteria=replacement_hysteria,
**({"prepared_amnezia": replacement_amnezia} if replacement_amnezia is not None else {}),
)
if (yield from _abort_security_recovery_steps(controller, hysteria_recovery, replacement_hysteria)):
return False
if (yield from _abort_superseded_transition_steps(
controller, requested_generation, replacement_hysteria, old_plan=old_plan, front_changed=True,
)):
return False
if not front_ready:
rolled_back = yield from controller._rollback_singbox_front_steps(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:
yield from controller._handle_unexpected_disconnect_steps()
return False
if replacement_hysteria is not None:
if not (yield from controller._commit_hysteria_replacement_steps(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 _manager_active(old_hysteria):
yield from old_hysteria.stop_steps(expected=True)
controller._hysteria_contract.advance(
HysteriaRuntimeState.STOPPING_OLD,
generation=controller._hysteria_contract.session.session_generation,
)
if not tun and not (yield from _apply_system_proxy_steps(controller, plan)):
yield from controller._handle_unexpected_disconnect_steps()
return False
if (yield from _abort_superseded_transition_steps(
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):
yield from old_amnezia.stop_steps()
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",
)
yield from controller._handle_unexpected_disconnect_steps()
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:
# finally не может ждать шагами: закрываем без ожидания, процесс
# добьёт собственный таймер, объект удалится после выхода.
replacement_amnezia.request_stop()
if getattr(replacement_amnezia, "process_alive", False) is True:
replacement_amnezia.stopped.connect(replacement_amnezia.deleteLater)
else:
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()
def _manager_active(manager) -> bool:
"""Running, or spawned but not yet confirmed ready (``process_alive``)."""
return bool(manager.is_running) or getattr(manager, "process_alive", False) is True