fix(network): trigger VPN bypass on handshake failure too

This commit is contained in:
klockky
2026-05-16 08:34:54 +03:00
parent bfde7133ca
commit 07290614ec
5 changed files with 113 additions and 35 deletions
@@ -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
View File
@@ -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();
+17 -26
View File
@@ -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);
+22 -8
View File
@@ -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 {
+24
View File
@@ -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();