fix(network): trigger VPN bypass on handshake failure too
This commit is contained in:
@@ -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<String, Any?> {
|
||||
val cm = connectivityManager()
|
||||
|
||||
+36
-1
@@ -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<void> disconnect() async {
|
||||
_autoReconnect = false;
|
||||
_bypassActive = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_cleanup();
|
||||
await _connection.disconnect();
|
||||
|
||||
@@ -30,41 +30,32 @@ class Connection {
|
||||
_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;
|
||||
_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);
|
||||
|
||||
@@ -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<VpnBypassResult>.broadcast();
|
||||
|
||||
/// Эмитит результат каждой попытки обхода (для уведомления в UI).
|
||||
Stream<VpnBypassResult> get events => _eventController.stream;
|
||||
|
||||
bool get _supported => Platform.isAndroid;
|
||||
|
||||
Future<bool> isEnabled() async {
|
||||
@@ -53,21 +59,22 @@ class VpnBypassService {
|
||||
await prefs.setBool(prefKey, value);
|
||||
}
|
||||
|
||||
/// true — обход включён, платформа поддерживается и виден VPN-туннель.
|
||||
/// true — обход включён, платформа поддерживается и активен VPN.
|
||||
Future<bool> shouldArm() async {
|
||||
if (!_supported) return false;
|
||||
if (!await isEnabled()) return false;
|
||||
return _hasTunInterface();
|
||||
return _isVpnActive();
|
||||
}
|
||||
|
||||
/// Привязывает процесс к non-VPN сети (wlan*/rmnet*).
|
||||
Future<VpnBypassResult> bind() async {
|
||||
VpnBypassResult result;
|
||||
try {
|
||||
final res = await _channel
|
||||
.invokeMapMethod<String, dynamic>('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<bool> _hasTunInterface() async {
|
||||
Future<bool> _isVpnActive() async {
|
||||
try {
|
||||
final res = await _channel
|
||||
.invokeMapMethod<String, dynamic>('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 {
|
||||
|
||||
@@ -96,6 +96,9 @@ class KometAppState extends State<KometApp> {
|
||||
bool _isLoggingOut = false;
|
||||
StreamSubscription<SessionExpiredException>? _sessionExpiredSub;
|
||||
StreamSubscription<LoginStatus>? _loginStatusSub;
|
||||
StreamSubscription<VpnBypassResult>? _vpnBypassSub;
|
||||
String? _lastVpnNotice;
|
||||
DateTime _lastVpnNoticeAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
||||
widget.initialFpsOverlay,
|
||||
);
|
||||
@@ -153,12 +156,33 @@ class KometAppState extends State<KometApp> {
|
||||
}
|
||||
_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();
|
||||
|
||||
Reference in New Issue
Block a user