v3.0.0: domain-free client, bootstrap tunnel, tunnel-internal API

This commit is contained in:
esefxdz committed 2026-08-02 14:00:38 +03:00
1 parent 75cf59f22c
commit 4fde4a9217
6 files changed
+158 -105

No files matched your search

+22 -8
View File
@@ -9,11 +9,26 @@ abstract final class AppConfig {
/// Your server's public IP. The VPN tunnel is pinned to this.
static const String serverIp = '0.0.0.0'; // ◄── REPLACE
/// API domain for session management (TLS traffic blending).
static const String serverDomain = 'yourdomain.com'; // ◄── REPLACE
/// API base — routed through the tunnel to Flask via Xray "api" outbound.
static const String hivemindApiBase = 'http://10.254.254.1:5000/api';
/// Hivemind API base URL — standard TLS to the domain.
static String get hivemindApiBase => 'https://$serverDomain/api';
// ── Bootstrap (first-connect config fetch) ────────────────────────────
/// Hardcoded Reality config. App connects with this, fetches the real
/// per-session VLESS URL through the tunnel, then reconnects.
static String get bootstrapVlessUrl =>
'vless://$bootstrapUuid@$serverIp:8443'
'?security=reality&type=xhttp&path=/revolt'
'&pbk=$realityPbk&sni=www.github.com&sid=$realitySid&fp=chrome'
'#ReVoltVPN';
/// Generate with `xray uuid` on the server. Must match xray_config_reality.json.
static const String bootstrapUuid = '00000000-0000-0000-0000-000000000000'; // ◄── REPLACE
/// Server's Reality public key (from `xray x25519`).
static const String realityPbk = 'REPLACE_WITH_YOUR_PUBLIC_KEY';
/// Must be in xray_config_reality.json shortIds[].
static const String realitySid = 'abc123';
// ── Reality (VPN tunnel) ──────────────────────────────────────────────
/// The server picks a random SNI per session from REALITY_SNI_POOL.
@@ -26,8 +41,7 @@ abstract final class AppConfig {
static const String vlessSecurity = 'reality';
static const String vlessType = 'xhttp';
/// XHTTP path — must match the server's xhttpSettings.path.
/// Server default: "/revolt" (hardcoded in xray_config_reality.json).
/// XHTTP path — must match xray_config_reality.json xhttpSettings.path.
static const String vlessPath = '/revolt';
// ── Updates ────────────────────────────────────────────────────────────
@@ -36,9 +50,9 @@ abstract final class AppConfig {
static const String githubOwner = 'YOUR_USERNAME'; // ◄── REPLACE
static const String githubRepo = 'revoltvpn'; // ◄── REPLACE
/// Fallback URL if the GitHub API call fails — just opens the releases page.
/// Fallback URL if the GitHub API call fails.
static const String githubReleasesUrl = 'https://github.com/$githubOwner/$githubRepo/releases/latest';
// ── AdMob ──────────────────────────────────────────────────────────────
static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE
}
}
+8 -3
View File
@@ -18,10 +18,14 @@ class HivemindService {
_expectedNonce = nonce;
}
static Future<String> fetchConfigWithPolling({
/// 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,
bool quickReconnect = false,
}) async {
final deviceId = await CryptoService.getDeviceId();
@@ -33,6 +37,7 @@ class HivemindService {
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});
@@ -42,7 +47,7 @@ class HivemindService {
} catch (_) {}
}
final maxAttempts = quickReconnect ? 1 : 5;
const maxAttempts = 5;
for (int i = 1; i <= maxAttempts; i++) {
if (_currentCallId != callId) throw Exception('Cancelled');
+99 -50
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_vless/flutter_vless.dart';
import 'package:revoltvpn/logic/hivemind_service.dart';
import 'package:revoltvpn/logic/app_config.dart';
enum VpnStatus {
disconnected,
@@ -58,7 +59,8 @@ class VpnConnection extends ChangeNotifier {
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
}
// Periodic server health check
// Periodic server health check — now goes through the tunnel
// (AppConfig.hivemindApiBase = http://10.254.254.1:5000/api)
_checkHealth();
_healthTimer =
Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth());
@@ -103,6 +105,18 @@ class VpnConnection extends ChangeNotifier {
}
}
// ── Connect (V2: bootstrap → poll → real tunnel) ────────────────────
//
// Flow:
// 1. Connect with hardcoded bootstrap VLESS URL (bundle in APK)
// 2. Through bootstrap tunnel, poll 10.254.254.1:5000 for real config
// 3. Disconnect bootstrap
// 4. Connect with real per-session VLESS URL
//
// Parameter quickReconnect is used by the session timer for throttle
// port-swaps — same bootstrap flow, but skip the AdMob bypass (session
// is already active).
Future<bool> connect(
{bool skipAdBypass = false, bool quickReconnect = false}) async {
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
@@ -116,7 +130,7 @@ class VpnConnection extends ChangeNotifier {
return false;
}
_setStatus(VpnStatus.connecting, 'Fetching config…');
_setStatus(VpnStatus.connecting, 'Establishing secure channel…');
_errorMessage = null;
if (kIsWeb) {
@@ -125,11 +139,36 @@ class VpnConnection extends ChangeNotifier {
return true;
}
String vlessUrl;
// ── Phase 1: Bootstrap tunnel ────────────────────────────────────
try {
vlessUrl = await HivemindService.fetchConfigWithPolling(
final bootstrapParsed = FlutterVless.parse(AppConfig.bootstrapVlessUrl);
await _vless.startVless(
remark: 'ReVoltVPN-Bootstrap',
config: bootstrapParsed.getFullConfiguration(),
);
} catch (e) {
debugPrint('[VPN] Bootstrap tunnel error: $e');
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
_setStatus(VpnStatus.error, 'Connection failed');
return false;
}
if (_cancelled) {
await _safeStopVless();
_setStatus(VpnStatus.disconnected, 'Tap to connect');
return false;
}
// Let the bootstrap tunnel stabilise before API calls
await Future.delayed(const Duration(seconds: 1));
// ── Phase 2: Fetch real config through bootstrap tunnel ──────────
_setStatus(VpnStatus.connecting, 'Fetching config…');
String realUrl;
try {
realUrl = await HivemindService.fetchConfigThroughTunnel(
skipAdBypass: skipAdBypass,
quickReconnect: quickReconnect,
onAttempt: (attempt, total) {
if (!quickReconnect) {
_setStatus(
@@ -138,21 +177,17 @@ class VpnConnection extends ChangeNotifier {
},
);
} catch (e) {
debugPrint('[VPN] Hivemind config error: $e');
debugPrint('[VPN] Config fetch error: $e');
await _safeStopVless();
final raw = e.toString().replaceAll('Exception: ', '');
if (raw.contains('Cancelled')) {
// User tapped disconnect — silently abort
return false;
} else if (raw.contains('timed out') ||
raw.contains('Session not activated')) {
_errorMessage =
'The server did not respond in time.\nCheck your connection and try again.';
_setStatus(VpnStatus.error, 'Server unreachable');
} else if (raw.contains('SocketException') ||
raw.contains('Failed host lookup')) {
_errorMessage =
'Cannot reach the server.\nMake sure you have internet access.';
_setStatus(VpnStatus.error, 'No internet');
} else {
_errorMessage = raw;
final short = raw.length > 35 ? '${raw.substring(0, 35)}…' : raw;
@@ -162,56 +197,59 @@ class VpnConnection extends ChangeNotifier {
}
if (_cancelled) {
await _safeStopVless();
_setStatus(VpnStatus.disconnected, 'Tap to connect');
return false;
}
// ── Phase 3: Switch to real tunnel ───────────────────────────────
_setStatus(VpnStatus.connecting, 'Securing connection…');
await _safeStopVless();
try {
final parsed = FlutterVless.parse(vlessUrl);
final config = parsed.getFullConfiguration();
final realParsed = FlutterVless.parse(realUrl);
await _vless.startVless(
remark: parsed.remark.isNotEmpty ? parsed.remark : 'ReVoltVPN',
config: config,
remark: realParsed.remark.isNotEmpty ? realParsed.remark : 'ReVoltVPN',
config: realParsed.getFullConfiguration(),
);
if (quickReconnect) {
_setStatus(VpnStatus.connected, 'Secured');
} else {
// Give the tunnel a moment to stabilise
_setStatus(VpnStatus.connecting, 'Verifying session…');
await Future.delayed(const Duration(seconds: 1));
bool sessionOk = false;
for (int attempt = 0; attempt < 2; attempt++) {
sessionOk = await HivemindService.verifySession();
if (sessionOk) break;
if (attempt == 0) {
debugPrint('[VPN] verifySession attempt 1 failed, retrying…');
await Future.delayed(const Duration(seconds: 2));
}
}
if (!sessionOk) {
debugPrint(
'[VPN] Tunnel started but no server session — tearing down.');
try {
await _vless.stopVless();
} catch (_) {}
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
_setStatus(VpnStatus.error, 'Session rejected');
return false;
}
_setStatus(VpnStatus.connected, 'Secured');
}
return true;
} catch (e) {
debugPrint('[VPN] VLESS tunnel error: $e');
debugPrint('[VPN] Real tunnel error: $e');
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
_setStatus(VpnStatus.error, 'Connection failed');
return false;
}
if (quickReconnect) {
_setStatus(VpnStatus.connected, 'Secured');
} else {
// Give the tunnel a moment to stabilise, then verify
_setStatus(VpnStatus.connecting, 'Verifying session…');
await Future.delayed(const Duration(seconds: 1));
bool sessionOk = false;
for (int attempt = 0; attempt < 2; attempt++) {
sessionOk = await HivemindService.verifySession();
if (sessionOk) break;
if (attempt == 0) {
debugPrint('[VPN] verifySession attempt 1 failed, retrying…');
await Future.delayed(const Duration(seconds: 2));
}
}
if (!sessionOk) {
debugPrint(
'[VPN] Tunnel started but no server session — tearing down.');
try {
await _vless.stopVless();
} catch (_) {}
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
_setStatus(VpnStatus.error, 'Session rejected');
return false;
}
_setStatus(VpnStatus.connected, 'Secured');
}
return true;
}
Future<void> disconnect({bool skipCleanup = false}) async {
@@ -256,6 +294,17 @@ class VpnConnection extends ChangeNotifier {
notifyListeners();
}
Future<void> _safeStopVless() async {
try {
await _vless.stopVless().timeout(
const Duration(seconds: 5),
onTimeout: () => debugPrint('[VPN] Bootstrap stopVless() timed out.'),
);
} catch (e) {
debugPrint('[VPN] Bootstrap stop error: $e');
}
}
@override
void dispose() {
_healthTimer?.cancel();