From 07290614ec5b77f907e5f8607d2ff9f4891c02b2 Mon Sep 17 00:00:00 2001 From: klockky Date: Sat, 16 May 2026 08:34:54 +0300 Subject: [PATCH] fix(network): trigger VPN bypass on handshake failure too --- .../main/kotlin/ru/komet/app/MainActivity.kt | 14 ++++++ lib/backend/api.dart | 37 +++++++++++++++- lib/core/transport/connection.dart | 43 ++++++++----------- lib/core/transport/vpn_bypass.dart | 30 +++++++++---- lib/main.dart | 24 +++++++++++ 5 files changed, 113 insertions(+), 35 deletions(-) diff --git a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt index 5cbf34a..9459159 100644 --- a/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/komet/app/MainActivity.kt @@ -60,11 +60,25 @@ class MainActivity : FlutterActivity() { } return mapOf( "hasTun" to tunNames.isNotEmpty(), + "hasVpn" to hasVpnTransport(), "tunNames" to tunNames, "directInterfaces" to directNames, ) } + // VPN активен, даже если tun-интерфейс не виден приложению (Android 10+). + private fun hasVpnTransport(): Boolean { + val cm = connectivityManager() + for (network in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(network) ?: continue + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return true + if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) { + return true + } + } + return false + } + // Привязывает процесс к не-VPN сети: Wi-Fi → Ethernet → моб. private fun bindToNonVpnNetwork(): Map { val cm = connectivityManager() diff --git a/lib/backend/api.dart b/lib/backend/api.dart index d170153..eba505c 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -10,6 +10,7 @@ import '../core/transport/connection.dart'; import '../core/transport/dispatcher.dart'; import '../core/transport/receiver.dart'; import '../core/transport/sender.dart'; +import '../core/transport/vpn_bypass.dart'; import '../core/utils/logger.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -56,6 +57,9 @@ class Api { int _reconnectAttempts = 0; bool _autoReconnect = false; + /// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля. + bool _bypassActive = false; + // Публичное API /// Подключается к серверу, шлёт хэндшейк, запускает пинг. @@ -73,14 +77,28 @@ class Api { } }); + final bypassArmed = await VpnBypassService.instance.shouldArm(); + if (!bypassArmed) _bypassActive = false; + final useBypass = _bypassActive && bypassArmed; + // Попытку через VPN ограничиваем по времени, чтобы быстро понять, + // что туннель не пропускает, и переключиться на обход. + final attemptTimeout = + bypassArmed && !useBypass ? const Duration(seconds: 8) : null; + try { final endpoint = await ServerConfig.loadEndpoint(); - await _connection.connect(endpoint.host, endpoint.port); + await _connection.connect( + endpoint.host, + endpoint.port, + bypassVpn: useBypass, + timeout: attemptTimeout, + ); } catch (e) { logger.e('Не удалось подключиться: $e'); if (_sessionState != SessionState.disconnected) { _cleanup(); _setSessionState(SessionState.disconnected); + _armBypassIfPossible(bypassArmed, useBypass, 'подключение не удалось'); _scheduleReconnect(); } return; @@ -107,12 +125,29 @@ class Api { } } catch (e) { logger.e('Ошибка хэндшейка: $e'); + // Сокет подключился (через VPN), но сервер не ответил на хэндшейк — + // путь нерабочий: рвём соединение и пробуем мимо VPN. + if (_sessionState != SessionState.disconnected) { + _cleanup(); + await _connection.disconnect(); + _setSessionState(SessionState.disconnected); + _armBypassIfPossible(bypassArmed, useBypass, 'хэндшейк не прошёл'); + _scheduleReconnect(); + } + } + } + + void _armBypassIfPossible(bool armed, bool alreadyBypassing, String why) { + if (armed && !alreadyBypassing && !_bypassActive) { + _bypassActive = true; + logger.w('VPN bypass: $why — следующая попытка мимо VPN'); } } /// Отключается без автореконнекта. Future disconnect() async { _autoReconnect = false; + _bypassActive = false; _reconnectTimer?.cancel(); _cleanup(); await _connection.disconnect(); diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index dfee30a..cd0ea40 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -30,41 +30,32 @@ class Connection { _stateController.add(newState); } - Future connect(String host, int port) async { + Future connect( + String host, + int port, { + bool bypassVpn = false, + Duration? timeout, + }) async { if (_state != SocketState.disconnected) return; _setState(SocketState.connecting); try { final proxySettings = await ProxyConfig.load(); - // Обход VPN — только запасной путь: сначала обычное подключение - // (через VPN, если он поднят), и лишь при неудаче — мимо туннеля. - final bypassArmed = await VpnBypassService.instance.shouldArm(); - if (bypassArmed) { + // Решение «обходить VPN или нет» принимает вызывающий (Api): + // первая попытка идёт через VPN, при её провале — мимо туннеля. + if (bypassVpn) { + await VpnBypassService.instance.bind(); + } else { await VpnBypassService.instance.restoreDefault(); } - RawSecureSocket socket; - try { - socket = await _openSecureSocket( - host, - port, - proxySettings, - timeout: bypassArmed ? const Duration(seconds: 8) : null, - ); - } catch (e) { - if (!bypassArmed) rethrow; - final r = await VpnBypassService.instance.bind(); - if (!r.bound) { - logger.w('VPN bypass: обходить нечего (${r.reason})'); - rethrow; - } - logger.w( - 'Подключение через VPN не удалось ($e) — ' - 'идём в обход → ${r.boundInterface} (${r.transport})', - ); - socket = await _openSecureSocket(host, port, proxySettings); - } + final socket = await _openSecureSocket( + host, + port, + proxySettings, + timeout: timeout, + ); _socket = socket; _setState(SocketState.connected); diff --git a/lib/core/transport/vpn_bypass.dart b/lib/core/transport/vpn_bypass.dart index 2eeb729..7536139 100644 --- a/lib/core/transport/vpn_bypass.dart +++ b/lib/core/transport/vpn_bypass.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/services.dart'; @@ -41,6 +42,11 @@ class VpnBypassService { bool _bound = false; + final _eventController = StreamController.broadcast(); + + /// Эмитит результат каждой попытки обхода (для уведомления в UI). + Stream get events => _eventController.stream; + bool get _supported => Platform.isAndroid; Future isEnabled() async { @@ -53,21 +59,22 @@ class VpnBypassService { await prefs.setBool(prefKey, value); } - /// true — обход включён, платформа поддерживается и виден VPN-туннель. + /// true — обход включён, платформа поддерживается и активен VPN. Future shouldArm() async { if (!_supported) return false; if (!await isEnabled()) return false; - return _hasTunInterface(); + return _isVpnActive(); } /// Привязывает процесс к non-VPN сети (wlan*/rmnet*). Future bind() async { + VpnBypassResult result; try { final res = await _channel .invokeMapMethod('bindToNonVpnNetwork'); final bound = res?['bound'] == true; _bound = bound; - return VpnBypassResult( + result = VpnBypassResult( enabled: true, tunDetected: true, bound: bound, @@ -77,22 +84,29 @@ class VpnBypassService { ); } on PlatformException catch (e) { logger.e('VPN bypass: ошибка платформы: ${e.message}'); - return VpnBypassResult(enabled: true, tunDetected: true, reason: e.code); + result = VpnBypassResult( + enabled: true, + tunDetected: true, + reason: e.code, + ); } on MissingPluginException { - return const VpnBypassResult( + result = const VpnBypassResult( enabled: true, tunDetected: true, reason: 'no_plugin', ); } + _eventController.add(result); + return result; } - Future _hasTunInterface() async { + Future _isVpnActive() async { try { final res = await _channel .invokeMapMethod('detectInterfaces'); - if (res != null && res.containsKey('hasTun')) { - return res['hasTun'] == true; + if (res != null) { + if (res['hasTun'] == true || res['hasVpn'] == true) return true; + if (res.containsKey('hasTun')) return false; } } catch (_) {} try { diff --git a/lib/main.dart b/lib/main.dart index 848f518..bf40959 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -96,6 +96,9 @@ class KometAppState extends State { bool _isLoggingOut = false; StreamSubscription? _sessionExpiredSub; StreamSubscription? _loginStatusSub; + StreamSubscription? _vpnBypassSub; + String? _lastVpnNotice; + DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0); late final ValueNotifier fpsOverlayEnabled = ValueNotifier( widget.initialFpsOverlay, ); @@ -153,12 +156,33 @@ class KometAppState extends State { } _isLoggingOut = false; }); + + _vpnBypassSub = VpnBypassService.instance.events.listen((r) { + final msg = r.bound + ? 'Соединение через VPN не работает — ' + 'используется ${r.boundInterface ?? r.transport ?? 'прямое подключение'}' + : 'Соединение через VPN не работает, обойти не удалось'; + + final now = DateTime.now(); + if (msg == _lastVpnNotice && + now.difference(_lastVpnNoticeAt).inSeconds < 10) { + return; + } + _lastVpnNotice = msg; + _lastVpnNoticeAt = now; + + final overlay = KometApp.navigatorKey.currentState?.overlay; + if (overlay != null) { + showCustomNotificationOnOverlay(overlay, msg); + } + }); } @override void dispose() { _sessionExpiredSub?.cancel(); _loginStatusSub?.cancel(); + _vpnBypassSub?.cancel(); _profileUpdateController.close(); fpsOverlayEnabled.dispose(); vpnBypassEnabled.dispose();