feat(app): add secure routing and runtime resilience
Add explicit TUN and SOCKS5 connection modes, app routing controls, authenticated per-session local SOCKS5, Android network monitoring, persistent haptics, resilient session recovery, notification hardening and client-side Hivemind cleanup. Keep the hosted flutter_vless_android 1.1.5 runtime as the base and apply the Android compatibility/security patches at build time instead of restoring the unstable vendored runtime.
This commit is contained in:
1 parent
371db8da9f
commit
8ead3ff249
30 files changed
+3959
-484
No files matched your search
+153
-54
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:revoltvpn/logic/app_config.dart';
|
||||
@@ -12,14 +13,21 @@ class HivemindService {
|
||||
|
||||
static const _ua = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
||||
static const _pollBackoff = <Duration>[
|
||||
Duration(seconds: 1),
|
||||
Duration(seconds: 2),
|
||||
Duration(seconds: 3),
|
||||
Duration(seconds: 5),
|
||||
];
|
||||
|
||||
static Future<http.Response> directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
static Future<http.Response> directGet(
|
||||
Uri uri, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) {
|
||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
}
|
||||
static void cancel() => _currentCallId++;
|
||||
|
||||
static void setExpectedNonce(String nonce) {
|
||||
_expectedNonce = nonce;
|
||||
@@ -30,66 +38,34 @@ class HivemindService {
|
||||
bool skipAdBypass = false,
|
||||
}) async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
|
||||
final callId = ++_currentCallId;
|
||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
final nonce = _newNonce();
|
||||
_expectedNonce = nonce;
|
||||
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
|
||||
|
||||
final url = _publicUrl('/session/status?device_id=$deviceId');
|
||||
|
||||
if (!skipAdBypass) {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final fakeUrl = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
await _runConfiguredBypass(deviceId, nonce);
|
||||
}
|
||||
|
||||
const maxAttempts = 5;
|
||||
for (int i = 1; i <= maxAttempts; i++) {
|
||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
_throwIfCancelled(callId);
|
||||
onAttempt?.call(attempt, maxAttempts);
|
||||
|
||||
onAttempt?.call(i, maxAttempts);
|
||||
try {
|
||||
final response = await directGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
final serverNonce = data['nonce'] as String?;
|
||||
if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) {
|
||||
debugPrint('[HivemindService] Nonce mismatch — retrying…');
|
||||
} else if (data['active'] == true && data['vless_uuid'] != null) {
|
||||
final vlessUuid = data['vless_uuid'];
|
||||
final vlessIp = data['vless_ip'] ?? AppConfig.serverIp;
|
||||
final vlessPort = data['vless_port'] ?? 443;
|
||||
final pbk = data['reality_pbk'] ?? '';
|
||||
final sid = data['reality_sid'] ?? '';
|
||||
final sni = data['reality_sni'];
|
||||
if (sni == null) throw Exception('Server did not provide reality_sni');
|
||||
final fp = data['reality_fp'] ?? AppConfig.realityFp;
|
||||
final xhttpPath = data['xhttp_path'] ?? AppConfig.vlessPath;
|
||||
|
||||
final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
|
||||
'?security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'&path=$xhttpPath'
|
||||
'&pbk=${Uri.encodeComponent(pbk)}'
|
||||
'&sni=${Uri.encodeComponent(sni)}'
|
||||
'&sid=${Uri.encodeComponent(sid)}'
|
||||
'&fp=${Uri.encodeComponent(fp)}'
|
||||
'#Revolt VPN';
|
||||
_expectedNonce = null;
|
||||
return vlessUrl;
|
||||
}
|
||||
final session = await _fetchActiveSession(deviceId, nonce);
|
||||
if (session != null) {
|
||||
_expectedNonce = null;
|
||||
return session.toVlessUrl();
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.toString().contains('Cancelled')) rethrow;
|
||||
debugPrint('[HivemindService] Attempt $i failed: $e');
|
||||
if (_isCancelledError(e)) rethrow;
|
||||
debugPrint('[Hivemind] session attempt $attempt failed');
|
||||
}
|
||||
|
||||
if (i < maxAttempts) await Future.delayed(const Duration(seconds: 1));
|
||||
if (attempt < maxAttempts) {
|
||||
await Future.delayed(_pollBackoff[attempt - 1]);
|
||||
_throwIfCancelled(callId);
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception('Session not activated. Server callback may have timed out.');
|
||||
@@ -97,16 +73,139 @@ class HivemindService {
|
||||
|
||||
static Future<bool> checkHealth() async {
|
||||
try {
|
||||
final url = Uri.parse('${AppConfig.hivemindApiPublic}/health');
|
||||
final response = await directGet(url, timeout: const Duration(seconds: 3));
|
||||
final response = await directGet(
|
||||
_publicUrl('/health'),
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── URL builders ──────────────────────────────────────────────────
|
||||
static String _newNonce() {
|
||||
final random = Random.secure();
|
||||
final high = random.nextInt(0x7FFFFFFF);
|
||||
final low = random.nextInt(0x7FFFFFFF);
|
||||
return '$high-$low-${DateTime.now().microsecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
static Future<void> _runConfiguredBypass(String deviceId, String nonce) async {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final callback = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}',
|
||||
);
|
||||
await directGet(callback, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {
|
||||
// Validation bypass behavior is intentionally best-effort in this build.
|
||||
}
|
||||
}
|
||||
|
||||
static Future<_HivemindSessionConfig?> _fetchActiveSession(
|
||||
String deviceId,
|
||||
String nonce,
|
||||
) async {
|
||||
final statusUri = _publicUrl('/session/status').replace(
|
||||
queryParameters: {'device_id': deviceId},
|
||||
);
|
||||
final response = await directGet(statusUri);
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
final serverNonce = decoded['nonce'];
|
||||
if (_expectedNonce != null &&
|
||||
serverNonce is String &&
|
||||
serverNonce != nonce) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _HivemindSessionConfig.fromJson(decoded);
|
||||
}
|
||||
|
||||
static void _throwIfCancelled(int callId) {
|
||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||
}
|
||||
|
||||
static bool _isCancelledError(Object error) =>
|
||||
error.toString().contains('Cancelled');
|
||||
|
||||
static Uri _publicUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
||||
}
|
||||
|
||||
class _HivemindSessionConfig {
|
||||
final String uuid;
|
||||
final String host;
|
||||
final int port;
|
||||
final String publicKey;
|
||||
final String shortId;
|
||||
final String sni;
|
||||
final String fingerprint;
|
||||
final String path;
|
||||
|
||||
const _HivemindSessionConfig({
|
||||
required this.uuid,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.publicKey,
|
||||
required this.shortId,
|
||||
required this.sni,
|
||||
required this.fingerprint,
|
||||
required this.path,
|
||||
});
|
||||
|
||||
factory _HivemindSessionConfig.fromJson(Map<String, dynamic> json) {
|
||||
final uuid = _requiredString(json, 'vless_uuid');
|
||||
final host = _stringOr(json['vless_ip'], AppConfig.serverIp);
|
||||
final port = _portOr(json['vless_port'], 443);
|
||||
final sni = _requiredString(json, 'reality_sni');
|
||||
|
||||
return _HivemindSessionConfig(
|
||||
uuid: uuid,
|
||||
host: host,
|
||||
port: port,
|
||||
publicKey: _stringOr(json['reality_pbk'], ''),
|
||||
shortId: _stringOr(json['reality_sid'], ''),
|
||||
sni: sni,
|
||||
fingerprint: _stringOr(json['reality_fp'], AppConfig.realityFp),
|
||||
path: _stringOr(json['xhttp_path'], AppConfig.vlessPath),
|
||||
);
|
||||
}
|
||||
|
||||
String toVlessUrl() {
|
||||
return 'vless://$uuid@$host:$port'
|
||||
'?security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'&path=$path'
|
||||
'&pbk=${Uri.encodeComponent(publicKey)}'
|
||||
'&sni=${Uri.encodeComponent(sni)}'
|
||||
'&sid=${Uri.encodeComponent(shortId)}'
|
||||
'&fp=${Uri.encodeComponent(fingerprint)}'
|
||||
'#Revolt VPN';
|
||||
}
|
||||
|
||||
static String _requiredString(Map<String, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
throw FormatException('Missing required session field: $key');
|
||||
}
|
||||
|
||||
static String _stringOr(Object? value, String fallback) {
|
||||
return value is String && value.isNotEmpty ? value : fallback;
|
||||
}
|
||||
|
||||
static int _portOr(Object? value, int fallback) {
|
||||
final parsed = value is num ? value.toInt() : fallback;
|
||||
if (parsed <= 0 || parsed > 65535) {
|
||||
throw const FormatException('Invalid VLESS port');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user