feat(network): VPN bypass — bind to non-VPN net (wlan*/rmnet*) when tun detected + dev toggle

This commit is contained in:
klockky
2026-05-16 07:45:29 +03:00
parent 2830e697aa
commit cd848f73fb
5 changed files with 353 additions and 1 deletions
@@ -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")
}
}
+7
View File
@@ -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;
+147
View File
@@ -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),
+16
View File
@@ -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,