146 lines
5.3 KiB
Dart
146 lines
5.3 KiB
Dart
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';
|
|
import 'package:revoltvpn/logic/crypto_service.dart';
|
|
|
|
class HivemindService {
|
|
static String? _expectedNonce;
|
|
static int _currentCallId = 0;
|
|
|
|
static void cancel() {
|
|
_currentCallId++;
|
|
}
|
|
|
|
static void setExpectedNonce(String nonce) {
|
|
_expectedNonce = nonce;
|
|
}
|
|
|
|
/// Fetch a real VLESS session config from the tunnel-internal API.
|
|
///
|
|
/// Called AFTER the bootstrap tunnel is already up — all HTTP requests
|
|
/// go through the Reality tunnel to 10.254.254.1:5000 (redirected to
|
|
/// Flask by Xray's "api" freedom outbound).
|
|
static Future<String> fetchConfigThroughTunnel({
|
|
void Function(int attempt, int total)? onAttempt,
|
|
bool skipAdBypass = false,
|
|
}) async {
|
|
final deviceId = await CryptoService.getDeviceId();
|
|
|
|
final callId = ++_currentCallId;
|
|
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
|
_expectedNonce = nonce;
|
|
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
|
|
|
|
final url = _url('/session/status?device_id=$deviceId');
|
|
|
|
// Dev: fake ad callback when running in debug mode.
|
|
// Goes through the tunnel to 10.254.254.1:5000 — same internal API.
|
|
if (kDebugMode && !skipAdBypass) {
|
|
try {
|
|
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
|
final fakeUrl = _url(
|
|
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
|
await http.get(fakeUrl).timeout(const Duration(seconds: 8));
|
|
} catch (_) {}
|
|
}
|
|
|
|
const maxAttempts = 5;
|
|
for (int i = 1; i <= maxAttempts; i++) {
|
|
if (_currentCallId != callId) throw Exception('Cancelled');
|
|
|
|
onAttempt?.call(i, maxAttempts);
|
|
try {
|
|
final response = await http.get(url).timeout(const Duration(seconds: 5));
|
|
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'];
|
|
|
|
// vless_ip is pinned — even if the API is MITM'd, the tunnel
|
|
// never goes anywhere except the real server IP.
|
|
final vlessIp = data['vless_ip'] ?? AppConfig.serverIp;
|
|
if (vlessIp != AppConfig.serverIp) {
|
|
throw Exception('API returned unexpected tunnel IP — possible MITM');
|
|
}
|
|
|
|
final vlessPort = data['vless_port'] ?? 8443;
|
|
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;
|
|
|
|
// Host header not set — server doesn't require it.
|
|
// Client falls through: host → serverName (SNI) → address.
|
|
final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort'
|
|
'?security=${AppConfig.vlessSecurity}'
|
|
'&type=${AppConfig.vlessType}'
|
|
'&path=${Uri.encodeComponent(xhttpPath)}'
|
|
'&pbk=${Uri.encodeComponent(pbk)}'
|
|
'&sni=${Uri.encodeComponent(sni)}'
|
|
'&sid=${Uri.encodeComponent(sid)}'
|
|
'&fp=${Uri.encodeComponent(fp)}'
|
|
'#ReVoltVPN';
|
|
_expectedNonce = null;
|
|
return vlessUrl;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e.toString().contains('Cancelled')) rethrow;
|
|
debugPrint('[HivemindService] Attempt $i failed: $e');
|
|
}
|
|
|
|
if (i < maxAttempts) await Future.delayed(const Duration(seconds: 1));
|
|
}
|
|
|
|
throw Exception('Session not activated. Server callback may have timed out.');
|
|
}
|
|
|
|
static Future<bool> checkHealth() async {
|
|
try {
|
|
final url = _url('/health');
|
|
final response = await http.get(url).timeout(const Duration(seconds: 3));
|
|
return response.statusCode == 200;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static Future<bool> verifySession() async {
|
|
try {
|
|
final deviceId = await CryptoService.getDeviceId();
|
|
final url = _url('/session/status?device_id=$deviceId');
|
|
final response = await http.get(url).timeout(const Duration(seconds: 5));
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
return data['active'] == true;
|
|
}
|
|
} catch (_) {}
|
|
return false;
|
|
}
|
|
|
|
static Future<void> disconnectAndCleanup() async {
|
|
final deviceId = await CryptoService.getDeviceId();
|
|
final url = _url('/session/stop');
|
|
try {
|
|
await http.post(
|
|
url,
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({'device_id': deviceId}),
|
|
).timeout(const Duration(seconds: 3));
|
|
} catch (_) {}
|
|
}
|
|
|
|
static Uri _url(String path) {
|
|
return Uri.parse('${AppConfig.hivemindApiBase}$path');
|
|
}
|
|
}
|