v3.2.0 — DPI hardening, notification consolidation, disconnect refactor
User-Agent spoofed to Chrome on Android (was Dart/3.x). Domain renamed to userevolt.app. Branding to Revolt VPN. Removed duplicate notification, patched flutter_vless for custom icon + dark background. Merged 5 disconnect paths into single idempotent gate, fixed stopVless timeout silently lying about state. Removed kDebugMode guard from AdMob bypass.
This commit is contained in:
1 parent
cf50c3a01b
commit
54f949ca8e
20 files changed
+552
-402
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -10,6 +11,73 @@ class HivemindService {
|
||||
static String? _expectedNonce;
|
||||
static int _currentCallId = 0;
|
||||
|
||||
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 Future<http.Response> directGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return http.get(uri, headers: {'User-Agent': _ua}).timeout(timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> directPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return http.post(uri, headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': _ua,
|
||||
}, body: body).timeout(timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> proxyGet(Uri uri, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return _proxyRequest('GET', uri, timeout: timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> proxyPost(Uri uri, String body, {Duration timeout = const Duration(seconds: 5)}) {
|
||||
return _proxyRequest('POST', uri, body: body, timeout: timeout);
|
||||
}
|
||||
|
||||
static Future<http.Response> _proxyRequest(String method, Uri uri,
|
||||
{String? body, Duration timeout = const Duration(seconds: 5)}) async {
|
||||
final socket = await Socket.connect('127.0.0.1', 10808, timeout: timeout);
|
||||
try {
|
||||
final target = uri.toString();
|
||||
final headers = <String, String>{
|
||||
'Host': '${uri.host}:${uri.port}',
|
||||
'User-Agent': _ua,
|
||||
'Accept': '*/*',
|
||||
'Connection': 'close',
|
||||
};
|
||||
if (body != null) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = body.length.toString();
|
||||
}
|
||||
|
||||
final reqBuf = StringBuffer();
|
||||
reqBuf.write('$method $target HTTP/1.1\r\n');
|
||||
headers.forEach((k, v) => reqBuf.write('$k: $v\r\n'));
|
||||
reqBuf.write('\r\n');
|
||||
if (body != null) reqBuf.write(body);
|
||||
|
||||
socket.write(reqBuf.toString());
|
||||
await socket.flush();
|
||||
|
||||
final response = await socket.fold<String>(
|
||||
'',
|
||||
(prev, chunk) => prev + String.fromCharCodes(chunk),
|
||||
).timeout(timeout);
|
||||
|
||||
final parts = response.split('\r\n\r\n');
|
||||
if (parts.length < 2) throw Exception('Invalid proxy response');
|
||||
final headerBlock = parts[0];
|
||||
final respBody = parts.sublist(1).join('\r\n\r\n');
|
||||
|
||||
final headerLines = headerBlock.split('\r\n');
|
||||
final statusParts = headerLines[0].split(' ');
|
||||
final statusCode = int.parse(statusParts[1]);
|
||||
|
||||
return http.Response(respBody, statusCode);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
}
|
||||
@@ -18,12 +86,7 @@ class HivemindService {
|
||||
_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({
|
||||
static Future<String> fetchConfigDirectly({
|
||||
void Function(int attempt, int total)? onAttempt,
|
||||
bool skipAdBypass = false,
|
||||
}) async {
|
||||
@@ -34,16 +97,14 @@ class HivemindService {
|
||||
_expectedNonce = nonce;
|
||||
debugPrint('[HivemindService] Call #$callId — nonce: $nonce');
|
||||
|
||||
final url = _url('/session/status?device_id=$deviceId');
|
||||
final url = _publicUrl('/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) {
|
||||
if (!skipAdBypass) {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final fakeUrl = _url(
|
||||
final fakeUrl = _publicUrl(
|
||||
'/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}');
|
||||
await http.get(fakeUrl).timeout(const Duration(seconds: 8));
|
||||
await directGet(fakeUrl, timeout: const Duration(seconds: 8));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -53,7 +114,7 @@ class HivemindService {
|
||||
|
||||
onAttempt?.call(i, maxAttempts);
|
||||
try {
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 5));
|
||||
final response = await directGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
@@ -62,15 +123,8 @@ class HivemindService {
|
||||
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 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'];
|
||||
@@ -78,17 +132,15 @@ class HivemindService {
|
||||
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)}'
|
||||
'&path=$xhttpPath'
|
||||
'&pbk=${Uri.encodeComponent(pbk)}'
|
||||
'&sni=${Uri.encodeComponent(sni)}'
|
||||
'&sid=${Uri.encodeComponent(sid)}'
|
||||
'&fp=${Uri.encodeComponent(fp)}'
|
||||
'#ReVoltVPN';
|
||||
'#Revolt VPN';
|
||||
_expectedNonce = null;
|
||||
return vlessUrl;
|
||||
}
|
||||
@@ -106,8 +158,8 @@ class HivemindService {
|
||||
|
||||
static Future<bool> checkHealth() async {
|
||||
try {
|
||||
final url = _url('/health');
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 3));
|
||||
final url = Uri.parse('${AppConfig.hivemindApiPublic}/health');
|
||||
final response = await directGet(url, timeout: const Duration(seconds: 3));
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
@@ -117,8 +169,8 @@ class HivemindService {
|
||||
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));
|
||||
final url = _tunnelUrl('/session/status?device_id=$deviceId');
|
||||
final response = await proxyGet(url);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['active'] == true;
|
||||
@@ -127,19 +179,15 @@ class HivemindService {
|
||||
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 (_) {}
|
||||
}
|
||||
// Idle sessions are culled server-side by the management loop
|
||||
// after 2 minutes of no /status polls (user disconnected).
|
||||
static void disconnectAndCleanup() {}
|
||||
|
||||
static Uri _url(String path) {
|
||||
return Uri.parse('${AppConfig.hivemindApiBase}$path');
|
||||
}
|
||||
// ── URL builders ──────────────────────────────────────────────────
|
||||
|
||||
static Uri _publicUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiPublic}$path');
|
||||
|
||||
static Uri _tunnelUrl(String path) =>
|
||||
Uri.parse('${AppConfig.hivemindApiBase}$path');
|
||||
}
|
||||
Reference in new issue
Block a user