fix(network): trigger VPN bypass on handshake failure too
This commit is contained in:
@@ -60,11 +60,25 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"hasTun" to tunNames.isNotEmpty(),
|
"hasTun" to tunNames.isNotEmpty(),
|
||||||
|
"hasVpn" to hasVpnTransport(),
|
||||||
"tunNames" to tunNames,
|
"tunNames" to tunNames,
|
||||||
"directInterfaces" to directNames,
|
"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 → моб.
|
// Привязывает процесс к не-VPN сети: Wi-Fi → Ethernet → моб.
|
||||||
private fun bindToNonVpnNetwork(): Map<String, Any?> {
|
private fun bindToNonVpnNetwork(): Map<String, Any?> {
|
||||||
val cm = connectivityManager()
|
val cm = connectivityManager()
|
||||||
|
|||||||
+36
-1
@@ -10,6 +10,7 @@ import '../core/transport/connection.dart';
|
|||||||
import '../core/transport/dispatcher.dart';
|
import '../core/transport/dispatcher.dart';
|
||||||
import '../core/transport/receiver.dart';
|
import '../core/transport/receiver.dart';
|
||||||
import '../core/transport/sender.dart';
|
import '../core/transport/sender.dart';
|
||||||
|
import '../core/transport/vpn_bypass.dart';
|
||||||
import '../core/utils/logger.dart';
|
import '../core/utils/logger.dart';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
@@ -56,6 +57,9 @@ class Api {
|
|||||||
int _reconnectAttempts = 0;
|
int _reconnectAttempts = 0;
|
||||||
bool _autoReconnect = false;
|
bool _autoReconnect = false;
|
||||||
|
|
||||||
|
/// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля.
|
||||||
|
bool _bypassActive = false;
|
||||||
|
|
||||||
// Публичное API
|
// Публичное 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 {
|
try {
|
||||||
final endpoint = await ServerConfig.loadEndpoint();
|
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) {
|
} catch (e) {
|
||||||
logger.e('Не удалось подключиться: $e');
|
logger.e('Не удалось подключиться: $e');
|
||||||
if (_sessionState != SessionState.disconnected) {
|
if (_sessionState != SessionState.disconnected) {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
_setSessionState(SessionState.disconnected);
|
_setSessionState(SessionState.disconnected);
|
||||||
|
_armBypassIfPossible(bypassArmed, useBypass, 'подключение не удалось');
|
||||||
_scheduleReconnect();
|
_scheduleReconnect();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -107,12 +125,29 @@ class Api {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e('Ошибка хэндшейка: $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<void> disconnect() async {
|
Future<void> disconnect() async {
|
||||||
_autoReconnect = false;
|
_autoReconnect = false;
|
||||||
|
_bypassActive = false;
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
_cleanup();
|
_cleanup();
|
||||||
await _connection.disconnect();
|
await _connection.disconnect();
|
||||||
|
|||||||
@@ -30,41 +30,32 @@ class Connection {
|
|||||||
_stateController.add(newState);
|
_stateController.add(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> connect(String host, int port) async {
|
Future<void> connect(
|
||||||
|
String host,
|
||||||
|
int port, {
|
||||||
|
bool bypassVpn = false,
|
||||||
|
Duration? timeout,
|
||||||
|
}) async {
|
||||||
if (_state != SocketState.disconnected) return;
|
if (_state != SocketState.disconnected) return;
|
||||||
_setState(SocketState.connecting);
|
_setState(SocketState.connecting);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final proxySettings = await ProxyConfig.load();
|
final proxySettings = await ProxyConfig.load();
|
||||||
|
|
||||||
// Обход VPN — только запасной путь: сначала обычное подключение
|
// Решение «обходить VPN или нет» принимает вызывающий (Api):
|
||||||
// (через VPN, если он поднят), и лишь при неудаче — мимо туннеля.
|
// первая попытка идёт через VPN, при её провале — мимо туннеля.
|
||||||
final bypassArmed = await VpnBypassService.instance.shouldArm();
|
if (bypassVpn) {
|
||||||
if (bypassArmed) {
|
await VpnBypassService.instance.bind();
|
||||||
|
} else {
|
||||||
await VpnBypassService.instance.restoreDefault();
|
await VpnBypassService.instance.restoreDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
RawSecureSocket socket;
|
final socket = await _openSecureSocket(
|
||||||
try {
|
host,
|
||||||
socket = await _openSecureSocket(
|
port,
|
||||||
host,
|
proxySettings,
|
||||||
port,
|
timeout: timeout,
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
_socket = socket;
|
_socket = socket;
|
||||||
_setState(SocketState.connected);
|
_setState(SocketState.connected);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -41,6 +42,11 @@ class VpnBypassService {
|
|||||||
|
|
||||||
bool _bound = false;
|
bool _bound = false;
|
||||||
|
|
||||||
|
final _eventController = StreamController<VpnBypassResult>.broadcast();
|
||||||
|
|
||||||
|
/// Эмитит результат каждой попытки обхода (для уведомления в UI).
|
||||||
|
Stream<VpnBypassResult> get events => _eventController.stream;
|
||||||
|
|
||||||
bool get _supported => Platform.isAndroid;
|
bool get _supported => Platform.isAndroid;
|
||||||
|
|
||||||
Future<bool> isEnabled() async {
|
Future<bool> isEnabled() async {
|
||||||
@@ -53,21 +59,22 @@ class VpnBypassService {
|
|||||||
await prefs.setBool(prefKey, value);
|
await prefs.setBool(prefKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// true — обход включён, платформа поддерживается и виден VPN-туннель.
|
/// true — обход включён, платформа поддерживается и активен VPN.
|
||||||
Future<bool> shouldArm() async {
|
Future<bool> shouldArm() async {
|
||||||
if (!_supported) return false;
|
if (!_supported) return false;
|
||||||
if (!await isEnabled()) return false;
|
if (!await isEnabled()) return false;
|
||||||
return _hasTunInterface();
|
return _isVpnActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Привязывает процесс к non-VPN сети (wlan*/rmnet*).
|
/// Привязывает процесс к non-VPN сети (wlan*/rmnet*).
|
||||||
Future<VpnBypassResult> bind() async {
|
Future<VpnBypassResult> bind() async {
|
||||||
|
VpnBypassResult result;
|
||||||
try {
|
try {
|
||||||
final res = await _channel
|
final res = await _channel
|
||||||
.invokeMapMethod<String, dynamic>('bindToNonVpnNetwork');
|
.invokeMapMethod<String, dynamic>('bindToNonVpnNetwork');
|
||||||
final bound = res?['bound'] == true;
|
final bound = res?['bound'] == true;
|
||||||
_bound = bound;
|
_bound = bound;
|
||||||
return VpnBypassResult(
|
result = VpnBypassResult(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
tunDetected: true,
|
tunDetected: true,
|
||||||
bound: bound,
|
bound: bound,
|
||||||
@@ -77,22 +84,29 @@ class VpnBypassService {
|
|||||||
);
|
);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
logger.e('VPN bypass: ошибка платформы: ${e.message}');
|
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 {
|
} on MissingPluginException {
|
||||||
return const VpnBypassResult(
|
result = const VpnBypassResult(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
tunDetected: true,
|
tunDetected: true,
|
||||||
reason: 'no_plugin',
|
reason: 'no_plugin',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_eventController.add(result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _hasTunInterface() async {
|
Future<bool> _isVpnActive() async {
|
||||||
try {
|
try {
|
||||||
final res = await _channel
|
final res = await _channel
|
||||||
.invokeMapMethod<String, dynamic>('detectInterfaces');
|
.invokeMapMethod<String, dynamic>('detectInterfaces');
|
||||||
if (res != null && res.containsKey('hasTun')) {
|
if (res != null) {
|
||||||
return res['hasTun'] == true;
|
if (res['hasTun'] == true || res['hasVpn'] == true) return true;
|
||||||
|
if (res.containsKey('hasTun')) return false;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ class KometAppState extends State<KometApp> {
|
|||||||
bool _isLoggingOut = false;
|
bool _isLoggingOut = false;
|
||||||
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
||||||
StreamSubscription<LoginStatus>? _loginStatusSub;
|
StreamSubscription<LoginStatus>? _loginStatusSub;
|
||||||
|
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
||||||
|
String? _lastVpnNotice;
|
||||||
|
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
||||||
widget.initialFpsOverlay,
|
widget.initialFpsOverlay,
|
||||||
);
|
);
|
||||||
@@ -153,12 +156,33 @@ class KometAppState extends State<KometApp> {
|
|||||||
}
|
}
|
||||||
_isLoggingOut = false;
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_sessionExpiredSub?.cancel();
|
_sessionExpiredSub?.cancel();
|
||||||
_loginStatusSub?.cancel();
|
_loginStatusSub?.cancel();
|
||||||
|
_vpnBypassSub?.cancel();
|
||||||
_profileUpdateController.close();
|
_profileUpdateController.close();
|
||||||
fpsOverlayEnabled.dispose();
|
fpsOverlayEnabled.dispose();
|
||||||
vpnBypassEnabled.dispose();
|
vpnBypassEnabled.dispose();
|
||||||
|
|||||||
Reference in New Issue
Block a user