feat(network): VPN bypass — bind to non-VPN net (wlan*/rmnet*) when tun detected + dev toggle
This commit is contained in:
@@ -1,5 +1,120 @@
|
|||||||
package ru.komet.app
|
package ru.komet.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
private val channelName = "ru.komet.app/vpn_bypass"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
channelName,
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"detectInterfaces" -> result.success(detectInterfaces())
|
||||||
|
"bindToNonVpnNetwork" -> result.success(bindToNonVpnNetwork())
|
||||||
|
"unbindNetwork" -> result.success(unbindNetwork())
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectivityManager(): ConnectivityManager =
|
||||||
|
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
// Перечисляет активные интерфейсы: есть ли tun-туннель и какие прямые.
|
||||||
|
private fun detectInterfaces(): Map<String, Any> {
|
||||||
|
val tunNames = ArrayList<String>()
|
||||||
|
val directNames = ArrayList<String>()
|
||||||
|
val interfaces = try {
|
||||||
|
Collections.list(NetworkInterface.getNetworkInterfaces())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList<NetworkInterface>()
|
||||||
|
}
|
||||||
|
for (nif in interfaces) {
|
||||||
|
val name = nif.name ?: continue
|
||||||
|
val up = try {
|
||||||
|
nif.isUp && !nif.isLoopback
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!up) continue
|
||||||
|
when {
|
||||||
|
name.startsWith("tun") || name.startsWith("ppp") ||
|
||||||
|
name.startsWith("ipsec") || name.startsWith("wg") ->
|
||||||
|
tunNames.add(name)
|
||||||
|
name.startsWith("wlan") || name.startsWith("rmnet") ||
|
||||||
|
name.startsWith("eth") ->
|
||||||
|
directNames.add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mapOf(
|
||||||
|
"hasTun" to tunNames.isNotEmpty(),
|
||||||
|
"tunNames" to tunNames,
|
||||||
|
"directInterfaces" to directNames,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязывает процесс к не-VPN сети: Wi-Fi → Ethernet → моб.
|
||||||
|
private fun bindToNonVpnNetwork(): Map<String, Any?> {
|
||||||
|
val cm = connectivityManager()
|
||||||
|
var best: Network? = null
|
||||||
|
var bestIface: String? = null
|
||||||
|
var bestTransport: String? = null
|
||||||
|
var bestScore = -1
|
||||||
|
|
||||||
|
for (network in cm.allNetworks) {
|
||||||
|
val caps = cm.getNetworkCapabilities(network) ?: continue
|
||||||
|
if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) continue
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue
|
||||||
|
if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) continue
|
||||||
|
|
||||||
|
val baseScore = when {
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 3
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 2
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 1
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
val transport = when (baseScore) {
|
||||||
|
3 -> "wifi"
|
||||||
|
2 -> "ethernet"
|
||||||
|
else -> "cellular"
|
||||||
|
}
|
||||||
|
val validated =
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
val score = baseScore * 2 + if (validated) 1 else 0
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
best = network
|
||||||
|
bestIface = cm.getLinkProperties(network)?.interfaceName
|
||||||
|
bestTransport = transport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val chosen = best
|
||||||
|
?: return mapOf("bound" to false, "reason" to "no_non_vpn_network")
|
||||||
|
|
||||||
|
val ok = cm.bindProcessToNetwork(chosen)
|
||||||
|
return mapOf(
|
||||||
|
"bound" to ok,
|
||||||
|
"interface" to bestIface,
|
||||||
|
"transport" to bestTransport,
|
||||||
|
"reason" to if (ok) null else "bind_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unbindNetwork(): Map<String, Any?> {
|
||||||
|
connectivityManager().bindProcessToNetwork(null)
|
||||||
|
return mapOf("bound" to false, "reason" to "unbound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
|||||||
import '../config/proxy_config.dart';
|
import '../config/proxy_config.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import 'proxy_connector.dart';
|
import 'proxy_connector.dart';
|
||||||
|
import 'vpn_bypass.dart';
|
||||||
|
|
||||||
enum SocketState { disconnected, connecting, connected }
|
enum SocketState { disconnected, connecting, connected }
|
||||||
|
|
||||||
@@ -34,6 +35,12 @@ class Connection {
|
|||||||
_setState(SocketState.connecting);
|
_setState(SocketState.connecting);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
await VpnBypassService.instance.applyIfNeeded();
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('VPN bypass: пропущено ($e)');
|
||||||
|
}
|
||||||
|
|
||||||
final proxySettings = await ProxyConfig.load();
|
final proxySettings = await ProxyConfig.load();
|
||||||
RawSocket rawSocket;
|
RawSocket rawSocket;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../utils/logger.dart';
|
||||||
|
|
||||||
|
class VpnBypassResult {
|
||||||
|
final bool enabled;
|
||||||
|
final bool tunDetected;
|
||||||
|
final bool bound;
|
||||||
|
final String? boundInterface;
|
||||||
|
final String? transport;
|
||||||
|
final String? reason;
|
||||||
|
|
||||||
|
const VpnBypassResult({
|
||||||
|
required this.enabled,
|
||||||
|
this.tunDetected = false,
|
||||||
|
this.bound = false,
|
||||||
|
this.boundInterface,
|
||||||
|
this.transport,
|
||||||
|
this.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'VpnBypassResult(enabled: $enabled, tun: $tunDetected, bound: $bound, '
|
||||||
|
'iface: $boundInterface, transport: $transport, reason: $reason)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// При активном VPN (tun-интерфейс) привязывает процесс к не-VPN сети
|
||||||
|
/// (wlan*/rmnet*). Только Android, по умолчанию выключено.
|
||||||
|
class VpnBypassService {
|
||||||
|
VpnBypassService._();
|
||||||
|
static final VpnBypassService instance = VpnBypassService._();
|
||||||
|
|
||||||
|
static const String prefKey = 'dev_vpn_bypass';
|
||||||
|
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('ru.komet.app/vpn_bypass');
|
||||||
|
|
||||||
|
bool _bound = false;
|
||||||
|
|
||||||
|
bool get _supported => Platform.isAndroid;
|
||||||
|
|
||||||
|
Future<bool> isEnabled() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(prefKey) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setEnabled(bool value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(prefKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вызывается перед каждым (ре)коннектом.
|
||||||
|
Future<VpnBypassResult> applyIfNeeded() async {
|
||||||
|
if (!_supported) {
|
||||||
|
return const VpnBypassResult(
|
||||||
|
enabled: false,
|
||||||
|
reason: 'unsupported_platform',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await isEnabled()) {
|
||||||
|
await _restoreDefault();
|
||||||
|
return const VpnBypassResult(enabled: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tunDetected = await _hasTunInterface();
|
||||||
|
if (!tunDetected) {
|
||||||
|
await _restoreDefault();
|
||||||
|
logger.i('VPN bypass: tun-интерфейс не найден — маршрут по умолчанию');
|
||||||
|
return const VpnBypassResult(enabled: true, tunDetected: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _channel
|
||||||
|
.invokeMapMethod<String, dynamic>('bindToNonVpnNetwork');
|
||||||
|
final bound = res?['bound'] == true;
|
||||||
|
_bound = bound;
|
||||||
|
final result = VpnBypassResult(
|
||||||
|
enabled: true,
|
||||||
|
tunDetected: true,
|
||||||
|
bound: bound,
|
||||||
|
boundInterface: res?['interface'] as String?,
|
||||||
|
transport: res?['transport'] as String?,
|
||||||
|
reason: res?['reason'] as String?,
|
||||||
|
);
|
||||||
|
if (bound) {
|
||||||
|
logger.i(
|
||||||
|
'VPN bypass: трафик направлен мимо VPN → '
|
||||||
|
'${result.boundInterface} (${result.transport})',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.w('VPN bypass: не удалось обойти VPN (${result.reason})');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
logger.e('VPN bypass: ошибка платформы: ${e.message}');
|
||||||
|
return VpnBypassResult(
|
||||||
|
enabled: true,
|
||||||
|
tunDetected: true,
|
||||||
|
reason: e.code,
|
||||||
|
);
|
||||||
|
} on MissingPluginException {
|
||||||
|
return const VpnBypassResult(
|
||||||
|
enabled: true,
|
||||||
|
tunDetected: true,
|
||||||
|
reason: 'no_plugin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _hasTunInterface() async {
|
||||||
|
try {
|
||||||
|
final res = await _channel
|
||||||
|
.invokeMapMethod<String, dynamic>('detectInterfaces');
|
||||||
|
if (res != null && res.containsKey('hasTun')) {
|
||||||
|
return res['hasTun'] == true;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
final ifaces = await NetworkInterface.list(
|
||||||
|
includeLoopback: false,
|
||||||
|
includeLinkLocal: true,
|
||||||
|
);
|
||||||
|
return ifaces.any((i) {
|
||||||
|
final n = i.name.toLowerCase();
|
||||||
|
return n.startsWith('tun') ||
|
||||||
|
n.startsWith('ppp') ||
|
||||||
|
n.startsWith('ipsec') ||
|
||||||
|
n.startsWith('wg');
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreDefault() async {
|
||||||
|
if (!_bound) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('unbindNetwork');
|
||||||
|
} catch (_) {}
|
||||||
|
_bound = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,73 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: appState == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: appState.vpnBypassEnabled,
|
||||||
|
builder: (context, bypassOn, _) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.vpn_key_off,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Обход VPN',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Если обнаружен VPN (tun-интерфейс), '
|
||||||
|
'подключаться напрямую через Wi-Fi или '
|
||||||
|
'моб. сеть в обход туннеля. Только Android',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: bypassOn,
|
||||||
|
onChanged: (v) {
|
||||||
|
appState.setVpnBypassEnabled(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'backend/modules/contacts.dart';
|
|||||||
import 'backend/modules/messages.dart';
|
import 'backend/modules/messages.dart';
|
||||||
import 'core/push/push_service.dart';
|
import 'core/push/push_service.dart';
|
||||||
import 'core/storage/app_database.dart';
|
import 'core/storage/app_database.dart';
|
||||||
|
import 'core/transport/vpn_bypass.dart';
|
||||||
import 'core/storage/token_storage.dart';
|
import 'core/storage/token_storage.dart';
|
||||||
import 'core/utils/haptics.dart';
|
import 'core/utils/haptics.dart';
|
||||||
import 'core/protocol/packet.dart';
|
import 'core/protocol/packet.dart';
|
||||||
@@ -57,10 +58,12 @@ void main() async {
|
|||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false;
|
||||||
|
final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false;
|
||||||
runApp(
|
runApp(
|
||||||
KometApp(
|
KometApp(
|
||||||
initialLocale: initialLocale,
|
initialLocale: initialLocale,
|
||||||
initialFpsOverlay: initialFpsOverlay,
|
initialFpsOverlay: initialFpsOverlay,
|
||||||
|
initialVpnBypass: initialVpnBypass,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,10 +73,12 @@ class KometApp extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.initialLocale,
|
required this.initialLocale,
|
||||||
this.initialFpsOverlay = false,
|
this.initialFpsOverlay = false,
|
||||||
|
this.initialVpnBypass = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Locale initialLocale;
|
final Locale initialLocale;
|
||||||
final bool initialFpsOverlay;
|
final bool initialFpsOverlay;
|
||||||
|
final bool initialVpnBypass;
|
||||||
static final navigatorKey = GlobalKey<NavigatorState>();
|
static final navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
static KometAppState? stateOf(BuildContext context) {
|
static KometAppState? stateOf(BuildContext context) {
|
||||||
@@ -94,6 +99,9 @@ class KometAppState extends State<KometApp> {
|
|||||||
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
late final ValueNotifier<bool> fpsOverlayEnabled = ValueNotifier(
|
||||||
widget.initialFpsOverlay,
|
widget.initialFpsOverlay,
|
||||||
);
|
);
|
||||||
|
late final ValueNotifier<bool> vpnBypassEnabled = ValueNotifier(
|
||||||
|
widget.initialVpnBypass,
|
||||||
|
);
|
||||||
final _profileUpdateController = StreamController<void>.broadcast();
|
final _profileUpdateController = StreamController<void>.broadcast();
|
||||||
Stream<void> get profileUpdateStream => _profileUpdateController.stream;
|
Stream<void> get profileUpdateStream => _profileUpdateController.stream;
|
||||||
|
|
||||||
@@ -153,6 +161,7 @@ class KometAppState extends State<KometApp> {
|
|||||||
_loginStatusSub?.cancel();
|
_loginStatusSub?.cancel();
|
||||||
_profileUpdateController.close();
|
_profileUpdateController.close();
|
||||||
fpsOverlayEnabled.dispose();
|
fpsOverlayEnabled.dispose();
|
||||||
|
vpnBypassEnabled.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +172,13 @@ class KometAppState extends State<KometApp> {
|
|||||||
await prefs.setBool('dev_fps_overlay', value);
|
await prefs.setBool('dev_fps_overlay', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setVpnBypassEnabled(bool value) async {
|
||||||
|
if (vpnBypassEnabled.value == value) return;
|
||||||
|
vpnBypassEnabled.value = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(VpnBypassService.prefKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> applyLocale(Locale locale) async {
|
Future<void> applyLocale(Locale locale) async {
|
||||||
if (!AppLocalizations.supportedLocales.any(
|
if (!AppLocalizations.supportedLocales.any(
|
||||||
(l) => l.languageCode == locale.languageCode,
|
(l) => l.languageCode == locale.languageCode,
|
||||||
|
|||||||
Reference in New Issue
Block a user