zapret-kvn/xray_fluent/application/connection_service.py

220 lines
9.8 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 ctypes
import socket
from typing import TYPE_CHECKING
from ..constants import DEFAULT_XRAY_STATS_API_PORT
from ..engines.singbox.operations import capture_runtime_session
from ..engines.singbox import (
SingboxRuntimePlan,
start_proxy as start_singbox_proxy,
start_tun as start_singbox_tun,
)
if TYPE_CHECKING:
from .controller import AppController
def find_free_api_port(preferred: int | None = None, excluded: set[int] | None = None) -> int:
if preferred is None:
preferred = DEFAULT_XRAY_STATS_API_PORT
for port in range(preferred, preferred + 100):
if excluded and port in excluded:
continue
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(f"No free port in range {preferred}-{preferred + 100}")
def connect_selected(controller: AppController, allow_during_reconnect: bool = False) -> bool:
if controller._connecting:
return False
controller._connecting = True
generation = controller._transition_generation
try:
if controller._reconnecting and not allow_during_reconnect:
controller._set_connection_status("starting", "Переподключение...", level="info")
return False
if controller.locked:
controller._set_connection_status(
"error",
"Приложение заблокировано. Разблокируйте для подключения.",
level="warning",
)
return False
node = controller._runtime_selected_node()
if node is None and not controller._can_connect_without_selected_node():
message = "В конфиге есть outbound tag `proxy`. Сначала выберите сервер."
controller._set_connection_status("error", message, level="warning")
return False
controller._reset_auto_switch_state(
reset_cooldown=not controller._auto_switch_transitioning,
reset_cycle=not controller._auto_switch_transitioning,
)
prev_active_core = controller._active_core
tun = controller.state.settings.tun_mode
controller._xray_api_port = 0
session_label = node.name if node else controller.get_active_singbox_config_name()
if tun:
controller._log(f"[tun] attempting TUN connect, admin={_is_admin()}")
controller._set_connection_status("starting", f"Запуск VPN: {session_label}...", level="info")
if not _is_admin():
controller._log("[tun] NOT admin — aborting")
controller._set_connection_status(
"error",
"Режим TUN требует прав Администратора. Запустите приложение от имени Администратора.",
level="error",
)
return False
# Перед TUN принудительно убираем только НАШ системный прокси
# (или восстанавливаем бэкап); чужой прокси не отключаем.
controller.proxy.release_if_owned(restore_previous=True)
controller._tun_log_count = 0
result = start_singbox_tun(controller, node, prev_active_core=prev_active_core)
else:
result = start_singbox_proxy(controller, node, prev_active_core=prev_active_core)
if result is None:
return False
if generation != controller._transition_generation:
controller._stop_active_connection_processes(disable_proxy=not controller._desired_connected)
controller._refresh_connected_state()
return False
singbox_plan: SingboxRuntimePlan = result.plan
session_label = result.session_label
session_node = node
if not singbox_plan.used_selected_node:
session_node = None
outbound_pool_tags = singbox_plan.selector_tags
control_core = "xray" if singbox_plan.is_hybrid else "singbox"
if not controller._pin_started_outbound(session_node, control_core, outbound_pool_tags):
controller._set_connection_status(
"error",
"Ядро запущено, но не подтвердило выбор активного сервера.",
level="error",
)
controller._stop_active_connection_processes(disable_proxy=True)
return False
if generation != controller._transition_generation or not controller._desired_connected:
controller._stop_active_connection_processes(disable_proxy=not controller._desired_connected)
controller._refresh_connected_state()
return False
controller._set_connection_status(
"running",
f"Подключено: {session_label}"
+ (
" (TUN, xray sidecar)"
if tun and singbox_plan is not None and singbox_plan.is_hybrid
else " (TUN, Hysteria2)"
if tun and singbox_plan is not None and singbox_plan.is_hysteria_sidecar
else " (sing-box + Xray sidecar)"
if not tun and singbox_plan is not None and singbox_plan.is_hybrid
else " (sing-box + Hysteria2)"
if not tun and singbox_plan is not None and singbox_plan.is_hysteria_sidecar
else " (sing-box extended)"
if not tun and singbox_plan is not None
else " (TUN)" if tun else ""
),
level="success",
)
capture_runtime_session(controller, singbox_plan, node, tun=tun)
if not controller._commit_pending_transport_selection(session_node):
controller._handle_unexpected_disconnect()
return False
controller.schedule_save()
controller._traffic_history.start_session(session_label, "singbox")
# П5 (AC13): подключение состоялось (сессия зафиксирована, статус
# running) — фоновый прогрев DNS-кэша zapret для всех нод пула.
controller._start_proxy_dns_prewarm()
return True
finally:
controller._connecting = False
def disconnect_current(controller: AppController, disable_proxy: bool = True, emit_status: bool = True) -> bool:
controller._disconnecting = True
# A pending recovery may have set switching before a runner was started.
# A user disconnect owns that state too; otherwise callbacks stay muted.
if not controller._reconnecting:
controller._switching = False
controller._hysteria_recovery_active = False
try:
controller._cleanup_connection_runtime_state(
end_traffic_session=True,
reset_auto_switch_cycle=not controller._auto_switch_transitioning,
reset_auto_switch_cooldown=not controller._reconnecting and not controller._auto_switch_transitioning,
)
active_tun = controller._active_session.tun_mode if controller._active_session is not None else controller.state.settings.tun_mode
if emit_status and active_tun:
controller.status.emit("info", "Остановка VPN...")
stopped = controller._stop_active_connection_processes(disable_proxy=disable_proxy)
if stopped:
controller._active_core = "singbox"
controller._clear_active_session()
was_connected, connected = controller._refresh_connected_state()
if was_connected != connected and not controller._switching:
controller.connection_changed.emit(connected)
if emit_status:
if stopped:
controller._set_connection_status("idle", "Отключено", level="info")
else:
controller._set_connection_status("error", "Не удалось корректно остановить подключение", level="error")
return stopped
finally:
controller._disconnecting = False
def reconnect(controller: AppController, reason: str) -> bool:
if controller._reconnecting:
return False
controller._reconnecting = True
controller._switching = True
try:
controller._log(f"[reconnect] {reason}")
controller._set_connection_status("starting", "Переподключение...", level="info")
stopped = disconnect_current(controller, disable_proxy=False, emit_status=False)
if not stopped:
controller._set_connection_status("error", "Не удалось остановить предыдущий процесс Xray", level="error")
if controller.state.settings.enable_system_proxy:
controller.proxy.disable(restore_previous=True)
return False
ok = connect_selected(controller, allow_during_reconnect=True)
if not ok and controller.state.settings.enable_system_proxy:
controller.proxy.disable(restore_previous=True)
return ok
finally:
controller._reconnecting = False
controller._switching = False
controller._auto_switch_transitioning = 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 _is_admin() -> bool:
if not hasattr(ctypes, "windll"):
return False
try:
return bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception:
return False