v1.0.0: AWG -> VLESS migration — ReVoltVPN debut
This commit is contained in:
1 parent
b679ba4d8f
commit
aaf9887f49
29 files changed
+1071
-1401
No files matched your search
@@ -98,10 +98,8 @@ class AdManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch device credentials for Server-Side Verification (SSV)
|
||||
// 2. Fetch device ID for Server-Side Verification (SSV)
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final keys = await CryptoService.getOrCreateKeys();
|
||||
final pubKey = keys['publicKey']!;
|
||||
|
||||
// 3. Generate a connect nonce so the subsequent poll can verify
|
||||
// it's talking to the session created by *this* ad.
|
||||
@@ -113,7 +111,6 @@ class AdManager extends ChangeNotifier {
|
||||
final ssvOptions = ServerSideVerificationOptions(
|
||||
customData: jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'public_key': pubKey,
|
||||
'ad_type': adType,
|
||||
'nonce': nonce,
|
||||
}),
|
||||
|
||||
@@ -1,47 +1,26 @@
|
||||
// =============================================================================
|
||||
// app_config.example.dart — TEMPLATE FOR SERVER SETTINGS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rename this file to `app_config.dart` and fill in your real values below.
|
||||
// Rename this file to `app_config.dart` and fill in your real values.
|
||||
// Do not commit your real `app_config.dart` to GitHub!
|
||||
// =============================================================================
|
||||
|
||||
class AppConfig {
|
||||
// ── STEP 1: Paste your Hetzner server IP here ─────────────────────────────
|
||||
static const String serverIp = '127.0.0.1'; // REPLACE WITH REAL IP
|
||||
abstract final class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
// ── STEP 2: WireGuard UDP traffic port (default: 51820) ───────────────────
|
||||
static const String wgPort = '51820'; // REPLACE WITH REAL PORT
|
||||
// ── Server ─────────────────────────────────────────────────────────────
|
||||
static const String serverDomain = 'yourdomain.com'; // REPLACE
|
||||
|
||||
// ── STEP 3: AmneziaWG DPI Obfuscation Settings ───────────────────────────
|
||||
// Copy these from your real app_config.dart or your amnezia-wg-easy server.
|
||||
// All zeros = AmneziaWG obfuscation disabled (no DPI protection).
|
||||
static const int awgJc = 0; // REPLACE
|
||||
static const int awgJmin = 0; // REPLACE
|
||||
static const int awgJmax = 0; // REPLACE
|
||||
static const int awgS1 = 0; // REPLACE
|
||||
static const int awgS2 = 0; // REPLACE
|
||||
static const int awgH1 = 0; // REPLACE
|
||||
static const int awgH2 = 0; // REPLACE
|
||||
static const int awgH3 = 0; // REPLACE
|
||||
static const int awgH4 = 0; // REPLACE
|
||||
static const String vlessPath = '/tunnel'; // REPLACE if different
|
||||
|
||||
// ── STEP 4: AdMob IDs ───────────────────────────────────────────────────
|
||||
// Also create android/admob.properties from the template in that folder.
|
||||
static const String admobAppId = 'ca-app-pub-0000000000000000~0000000000'; // REPLACE
|
||||
static String get hivemindApiBase => 'https://$serverDomain/api';
|
||||
|
||||
// ── VLESS ──────────────────────────────────────────────────────────────
|
||||
static const int serverPort = 443;
|
||||
static const String vlessFlow = 'xtls-rprx-vision';
|
||||
static const String vlessSecurity = 'tls';
|
||||
static const String vlessType = 'ws';
|
||||
|
||||
// ── AdMob ──────────────────────────────────────────────────────────────
|
||||
static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE
|
||||
|
||||
// ── STEP 5: Network & Tunnel Settings ────────────────────────────────────
|
||||
static const String dnsServers = '1.1.1.1, 1.0.0.1, 2606:4700:4700::1111, 2606:4700:4700::1001';
|
||||
static const String allowedIps = '0.0.0.0/0, ::/0';
|
||||
static const int mtu = 1280;
|
||||
static const int persistentKeepalive = 25;
|
||||
static const int hivemindPort = 5000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Nothing below this line needs to be changed.
|
||||
// ── STEP 1: Hivemind Server API ──────────────────────────────────────────
|
||||
static String get hivemindApiBase => 'https://yourdomain.duckdns.org/api'; // REPLACE WITH REAL DOMAIN
|
||||
|
||||
/// The full server address passed to the WireGuard tunnel (IP:port).
|
||||
static String get serverEndpoint => '$serverIp:$wgPort';
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class CryptoService {
|
||||
static const String _privKeyPref = 'wg_private_key';
|
||||
static const String _pubKeyPref = 'wg_public_key';
|
||||
static const String _deviceIdPref = 'device_uuid';
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
// ── Get or create the persistent device UUID ──────────────────────────────
|
||||
/// Get or create a persistent device UUID for server-side session tracking.
|
||||
static Future<String> getDeviceId() async {
|
||||
final existing = await _storage.read(key: _deviceIdPref);
|
||||
if (existing != null) return existing;
|
||||
@@ -19,28 +14,4 @@ class CryptoService {
|
||||
await _storage.write(key: _deviceIdPref, value: newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
// ── Get or create the X25519 keypair for WireGuard ────────────────────────
|
||||
static Future<Map<String, String>> getOrCreateKeys() async {
|
||||
String? privBase64 = await _storage.read(key: _privKeyPref);
|
||||
String? pubBase64 = await _storage.read(key: _pubKeyPref);
|
||||
|
||||
if (privBase64 != null && pubBase64 != null) {
|
||||
return {'privateKey': privBase64, 'publicKey': pubBase64};
|
||||
}
|
||||
|
||||
final algorithm = X25519();
|
||||
final keyPair = await algorithm.newKeyPair();
|
||||
|
||||
final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
|
||||
final publicKey = await keyPair.extractPublicKey();
|
||||
|
||||
privBase64 = base64Encode(privateKeyBytes);
|
||||
pubBase64 = base64Encode(publicKey.bytes);
|
||||
|
||||
await _storage.write(key: _privKeyPref, value: privBase64);
|
||||
await _storage.write(key: _pubKeyPref, value: pubBase64);
|
||||
|
||||
return {'privateKey': privBase64, 'publicKey': pubBase64};
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class HivemindService {
|
||||
}
|
||||
|
||||
/// Polls the Hivemind server until the session is ready or timeout occurs.
|
||||
/// Returns the WireGuard config string.
|
||||
/// Returns a VLESS connection URL string.
|
||||
///
|
||||
/// [onAttempt] is called after each poll with (current, total) so the UI
|
||||
/// can show progress (e.g. "Contacting server (3/15)…").
|
||||
@@ -29,30 +29,19 @@ class HivemindService {
|
||||
void Function(int attempt, int total)? onAttempt,
|
||||
}) async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
final keys = await CryptoService.getOrCreateKeys();
|
||||
final privKey = keys['privateKey']!;
|
||||
|
||||
// ── Generate a connect nonce ──────────────────────────────────────
|
||||
// Every fresh connect cycle gets a new random token. The server
|
||||
// stores it in the session and echoes it back in /session/status.
|
||||
// If the poll returns a session whose nonce differs (e.g. a stale
|
||||
// leftover from a failed disconnectAndCleanup), we skip it and keep
|
||||
// polling until the right session appears or the timeout fires.
|
||||
final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}';
|
||||
_expectedNonce = nonce;
|
||||
debugPrint('[HivemindService] Connect nonce: $nonce');
|
||||
|
||||
|
||||
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||
|
||||
// ── AD BYPASS (debug only) ────────────────────────────────────────
|
||||
// In debug mode, send a fake AdMob callback so the server creates
|
||||
// a session without a real ad. In release mode this code is
|
||||
// stripped — sessions are only created by real AdMob SSV callbacks.
|
||||
if (kDebugMode) {
|
||||
try {
|
||||
final customData = jsonEncode({
|
||||
'device_id': deviceId,
|
||||
'public_key': keys['publicKey']!,
|
||||
'ad_type': 'main_ad',
|
||||
'nonce': nonce,
|
||||
});
|
||||
@@ -71,52 +60,31 @@ class HivemindService {
|
||||
final response = await http.get(url).timeout(const Duration(seconds: 10));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
|
||||
|
||||
debugPrint('[HivemindService] Session found, checking nonce…');
|
||||
|
||||
// ── Nonce gate ──────────────────────────────────────────────
|
||||
// Reject any session whose nonce doesn't match what we sent.
|
||||
// If the server is old and doesn't return a nonce we still
|
||||
// accept it (backwards-compat), but a mismatch is a hard skip.
|
||||
final serverNonce = data['nonce'] as String?;
|
||||
if (_expectedNonce != null && serverNonce != null && serverNonce != _expectedNonce) {
|
||||
debugPrint('[HivemindService] Nonce mismatch (expected $_expectedNonce, got $serverNonce) — stale session, retrying…');
|
||||
// Fall through to retry — do NOT accept this session.
|
||||
} else if (data['active'] == true && data['client_ip'] != null && data['server_pubkey'] != null) {
|
||||
final clientIp = data['client_ip'];
|
||||
final clientIpv6 = data['client_ipv6'] ?? '';
|
||||
final serverPubkey = data['server_pubkey'];
|
||||
} else if (data['active'] == true && data['vless_uuid'] != null) {
|
||||
final vlessUuid = data['vless_uuid'];
|
||||
|
||||
// Build dual-stack Address: IPv4/16 + optional IPv6/80
|
||||
final addresses = clientIpv6.isNotEmpty
|
||||
? '$clientIp/16, $clientIpv6/80'
|
||||
: '$clientIp/16';
|
||||
// Build VLESS connection URL
|
||||
final vlessUrl = 'vless://$vlessUuid@${AppConfig.serverDomain}:${AppConfig.serverPort}'
|
||||
'?path=${Uri.encodeComponent(AppConfig.vlessPath)}'
|
||||
'&security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'&flow=${AppConfig.vlessFlow}'
|
||||
'#PaladinVPN';
|
||||
|
||||
// Construct the WireGuard config locally
|
||||
final configText = '''
|
||||
[Interface]
|
||||
PrivateKey = $privKey
|
||||
Address = $addresses
|
||||
DNS = ${AppConfig.dnsServers}
|
||||
MTU = ${AppConfig.mtu}
|
||||
Jc = ${AppConfig.awgJc}
|
||||
Jmin = ${AppConfig.awgJmin}
|
||||
Jmax = ${AppConfig.awgJmax}
|
||||
S1 = ${AppConfig.awgS1}
|
||||
S2 = ${AppConfig.awgS2}
|
||||
H1 = ${AppConfig.awgH1}
|
||||
H2 = ${AppConfig.awgH2}
|
||||
H3 = ${AppConfig.awgH3}
|
||||
H4 = ${AppConfig.awgH4}
|
||||
|
||||
[Peer]
|
||||
PublicKey = $serverPubkey
|
||||
AllowedIPs = ${AppConfig.allowedIps}
|
||||
Endpoint = ${AppConfig.serverEndpoint}
|
||||
PersistentKeepalive = ${AppConfig.persistentKeepalive}
|
||||
''';
|
||||
_expectedNonce = null; // consumed successfully
|
||||
return configText;
|
||||
return vlessUrl;
|
||||
} else if (data['active'] == true && data['client_ip'] != null) {
|
||||
// Server is still running AWG — VLESS client cannot use this.
|
||||
debugPrint('[HivemindService] Server returned AWG format (client_ip) — server migration needed.');
|
||||
throw Exception('Server is running the old AWG protocol. '
|
||||
'Please update the server to VLESS.');
|
||||
}
|
||||
} else {
|
||||
debugPrint('[HivemindService] Non-200 status: ${response.statusCode}');
|
||||
@@ -125,7 +93,7 @@ PersistentKeepalive = ${AppConfig.persistentKeepalive}
|
||||
debugPrint('[HivemindService] Polling exception: $e');
|
||||
// Ignore network errors and keep polling
|
||||
}
|
||||
|
||||
|
||||
attempts++;
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
@@ -134,8 +102,6 @@ PersistentKeepalive = ${AppConfig.persistentKeepalive}
|
||||
}
|
||||
|
||||
/// Quick one-shot check: does the server have a live session for this device?
|
||||
/// Used by VpnConnection after the tunnel starts to confirm the server-side
|
||||
/// session exists before reporting "connected" to the UI.
|
||||
static Future<bool> verifySession() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
|
||||
+76
-187
@@ -1,106 +1,71 @@
|
||||
// =============================================================================
|
||||
// vpn_connection.dart — WireGuard Tunnel Manager
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// This file manages the WireGuard tunnel on the Android device.
|
||||
// It is the bridge between the UI (the big connect button) and the actual
|
||||
// VPN tunnel running inside the Android operating system.
|
||||
//
|
||||
// HOW IT WORKS:
|
||||
// 1. When connect() is called, it first asks WgApiService to fetch the config
|
||||
// file for this device from the server (see wg_api_service.dart).
|
||||
// 2. It passes that config text to the wireguard_flutter plugin, which handles
|
||||
// all the native Android VPN stuff automatically.
|
||||
// 3. It listens to the native tunnel state and updates the UI accordingly.
|
||||
//
|
||||
// You never need to edit this file.
|
||||
// Server settings → app_config.dart
|
||||
// Server communication → wg_api_service.dart
|
||||
// =============================================================================
|
||||
// vpn_connection.dart — VLESS VPN Tunnel Manager
|
||||
// =============================================================================
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:wireguard_flutter/wireguard_flutter.dart';
|
||||
import 'package:paladinvpn/logic/app_config.dart';
|
||||
import 'package:flutter_vless/flutter_vless.dart';
|
||||
import 'package:paladinvpn/logic/hivemind_service.dart';
|
||||
|
||||
// ── VPN States ────────────────────────────────────────────────────────────────
|
||||
// These are the possible states the tunnel can be in.
|
||||
// The UI uses these to decide what to display (spinner, shield icon, etc.)
|
||||
enum VpnStatus {
|
||||
disconnected, // No tunnel active — idle state
|
||||
connecting, // Fetching config from server + starting tunnel
|
||||
connected, // Tunnel is up and routing traffic
|
||||
disconnecting, // Tearing down the tunnel
|
||||
error, // Something went wrong (server unreachable, etc.)
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
disconnecting,
|
||||
error,
|
||||
}
|
||||
|
||||
// ── VpnConnection ─────────────────────────────────────────────────────────────
|
||||
// This is a "ChangeNotifier" — it works like a Python observable/event emitter.
|
||||
// Whenever the state changes, it automatically notifies all widgets that are
|
||||
// listening, and they re-render themselves. You never call setState() manually.
|
||||
class VpnConnection extends ChangeNotifier {
|
||||
// Current tunnel state (starts disconnected)
|
||||
VpnStatus _status = VpnStatus.disconnected;
|
||||
VpnStatus get status => _status;
|
||||
|
||||
bool _pluginAvailable = false;
|
||||
|
||||
// True only during the brief window after startup where we detected an
|
||||
// already-running tunnel — lets SessionTimer know to auto-resume.
|
||||
bool _isStartupRestoration = false;
|
||||
bool get isStartupRestoration => _isStartupRestoration;
|
||||
|
||||
// Short message shown to the user under the connect button
|
||||
String _statusMessage = 'Tap to connect';
|
||||
String get statusMessage => _statusMessage;
|
||||
|
||||
// Optional error message shown if something goes wrong
|
||||
String? _errorMessage;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
// The internal WireGuard IP assigned to this device (e.g. 10.8.0.2)
|
||||
// Populated after a successful config fetch and used by SessionTimer.
|
||||
String? internalIp;
|
||||
// True when the app was killed and reopened while the VPN was running.
|
||||
bool _isStartupRestoration = false;
|
||||
bool get isStartupRestoration => _isStartupRestoration;
|
||||
|
||||
// The wireguard_flutter plugin instance — this is what actually talks to Android
|
||||
final _wireguard = WireGuardFlutter.instance;
|
||||
late final FlutterVless _vless;
|
||||
bool _initialized = false;
|
||||
|
||||
// Subscription to the native tunnel state stream
|
||||
StreamSubscription? _stageSub;
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────
|
||||
// Called automatically when the app starts (via Provider in main.dart)
|
||||
VpnConnection() {
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
// WireGuard doesn't work in a web browser — skip on web
|
||||
if (kIsWeb) return;
|
||||
|
||||
_vless = FlutterVless(
|
||||
onStatusChanged: (status) {
|
||||
debugPrint('[VPN] Status: state=${status.state} '
|
||||
'connection=${status.connectionState.name}');
|
||||
_mapStatus(status);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
// Register our tunnel interface with Android.
|
||||
// 'Paladin0' is just the name of the network interface — like "eth0"
|
||||
await _wireguard.initialize(interfaceName: 'Paladin0');
|
||||
_pluginAvailable = true;
|
||||
await _vless.initializeVless(
|
||||
providerBundleIdentifier: 'com.paladinvpn.app',
|
||||
);
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
debugPrint('WireGuard init error (expected on emulator): $e');
|
||||
debugPrint('[VPN] VLESS init error (expected on emulator): $e');
|
||||
}
|
||||
|
||||
// Start listening to native tunnel state changes from Android.
|
||||
// When Android reports the tunnel went up/down, _mapStage() is called.
|
||||
// Check if VPN was already running (app killed and reopened)
|
||||
try {
|
||||
_stageSub = _wireguard.vpnStageSnapshot.listen(_mapStage);
|
||||
} catch (_) {}
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
debugPrint('[VPN] Xray core version: $coreVersion');
|
||||
|
||||
// Check if the VPN was already running before the app started.
|
||||
// This handles the case where the user swiped the app away and reopened it.
|
||||
try {
|
||||
final currentStage = await _wireguard.stage();
|
||||
if (currentStage == VpnStage.connected) {
|
||||
final delay = await _vless.getConnectedServerDelay();
|
||||
if (delay > 0) {
|
||||
_isStartupRestoration = true;
|
||||
_mapStage(currentStage);
|
||||
// Clear the flag after a few seconds — only needed during app startup
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
_isStartupRestoration = false;
|
||||
});
|
||||
@@ -108,52 +73,34 @@ class VpnConnection extends ChangeNotifier {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Native State Mapping ──────────────────────────────────────────────────
|
||||
// Android sends back low-level states like "authenticating", "preparing", etc.
|
||||
// This function translates those into our simpler 5-state enum above.
|
||||
void _mapStage(VpnStage stage) {
|
||||
switch (stage) {
|
||||
case VpnStage.connected:
|
||||
void _mapStatus(VlessStatus status) {
|
||||
switch (status.connectionState) {
|
||||
case VlessConnectionState.connected:
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
break;
|
||||
case VpnStage.disconnected:
|
||||
case VlessConnectionState.disconnected:
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
break;
|
||||
case VpnStage.connecting:
|
||||
case VpnStage.waitingConnection:
|
||||
case VpnStage.authenticating:
|
||||
case VpnStage.preparing:
|
||||
case VpnStage.reconnect:
|
||||
case VlessConnectionState.connecting:
|
||||
_setStatus(VpnStatus.connecting, 'Establishing tunnel…');
|
||||
break;
|
||||
case VpnStage.disconnecting:
|
||||
case VpnStage.exiting:
|
||||
case VlessConnectionState.disconnecting:
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
break;
|
||||
default:
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
break;
|
||||
case VlessConnectionState.unknown:
|
||||
// Native backend emitted an unrecognized state — treat as error
|
||||
// only if we aren't already in a terminal state.
|
||||
if (_status != VpnStatus.connected &&
|
||||
_status != VpnStatus.disconnected) {
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect ───────────────────────────────────────────────────────────────
|
||||
// This is the main function — called after the user watches the rewarded ad.
|
||||
//
|
||||
// Step 1: Call the WG-Easy API to get the .conf for this device
|
||||
// Step 2: Pass that .conf to the WireGuard plugin to start the tunnel
|
||||
//
|
||||
// Returns true if the connection was successful, false if it failed.
|
||||
// The connect button uses this to decide whether to start the timer.
|
||||
Future<bool> connect() async {
|
||||
// Don't do anything if we're already connected or currently connecting
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Guard: native plugin never loaded (wrong arch, missing .so)
|
||||
if (!kIsWeb && !_pluginAvailable) {
|
||||
_errorMessage = 'VPN service unavailable. The native tunnel library '
|
||||
'could not be loaded on this device.';
|
||||
if (!kIsWeb && !_initialized) {
|
||||
_errorMessage = 'VPN service unavailable.';
|
||||
_setStatus(VpnStatus.error, 'Service unavailable');
|
||||
return false;
|
||||
}
|
||||
@@ -161,40 +108,31 @@ class VpnConnection extends ChangeNotifier {
|
||||
_setStatus(VpnStatus.connecting, 'Fetching config…');
|
||||
_errorMessage = null;
|
||||
|
||||
// ── Web / emulator fallback ────────────────────────────────────────────
|
||||
if (kIsWeb) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
_setStatus(VpnStatus.connected, 'Secured (dev mode)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 1: Fetch WireGuard config from Hivemind ────────────────────
|
||||
String configText;
|
||||
// ── Step 1: Fetch VLESS URL from Hivemind ──────────────────────────
|
||||
String vlessUrl;
|
||||
try {
|
||||
configText = await HivemindService.fetchConfigWithPolling(
|
||||
vlessUrl = await HivemindService.fetchConfigWithPolling(
|
||||
onAttempt: (attempt, total) {
|
||||
_setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
},
|
||||
);
|
||||
|
||||
// Parse the internal IP (e.g. "Address = 10.8.0.2/16" or "/32")
|
||||
final regex = RegExp(r'Address\s*=\s*([0-9\.]+)/');
|
||||
final match = regex.firstMatch(configText);
|
||||
if (match != null) {
|
||||
internalIp = match.group(1);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Hivemind config error: $e');
|
||||
debugPrint('[VPN] Hivemind config error: $e');
|
||||
final raw = e.toString().replaceAll('Exception: ', '');
|
||||
// Map the known failures to something the user can act on
|
||||
if (raw.contains('timed out') || raw.contains('Session not activated')) {
|
||||
_errorMessage = 'The server did not respond in time.\n'
|
||||
'Check your internet connection and try again.';
|
||||
if (raw.contains('AWG protocol') || raw.contains('old AWG')) {
|
||||
_errorMessage = 'Server update required.\nThe VPN server needs to be upgraded to VLESS.';
|
||||
_setStatus(VpnStatus.error, 'Server outdated');
|
||||
} 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 VPN server.\n'
|
||||
'Make sure you have an active internet connection.';
|
||||
} 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;
|
||||
@@ -204,24 +142,20 @@ class VpnConnection extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Step 2: Start the WireGuard tunnel with the fetched config ──────
|
||||
// ── Step 2: Parse and start VLESS tunnel ───────────────────────────
|
||||
try {
|
||||
// Re-initialize to ensure VPN permission is granted before starting
|
||||
await _wireguard.initialize(interfaceName: 'Paladin0');
|
||||
await _wireguard.startVpn(
|
||||
serverAddress: AppConfig.serverEndpoint,
|
||||
wgQuickConfig: configText,
|
||||
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider',
|
||||
final parsed = FlutterVless.parse(vlessUrl);
|
||||
final config = parsed.getFullConfiguration();
|
||||
|
||||
await _vless.startVless(
|
||||
remark: parsed.remark.isNotEmpty ? parsed.remark : 'PaladinVPN',
|
||||
config: config,
|
||||
);
|
||||
|
||||
// ── Step 3: Verify the server session is alive ──────────────────
|
||||
// The tunnel just came up — give it a moment to stabilise before
|
||||
// making an HTTP call through it, otherwise DNS/resolution can fail.
|
||||
// Give the tunnel a moment to stabilise
|
||||
_setStatus(VpnStatus.connecting, 'Verifying session…');
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Retry once if the first attempt fails — the tunnel may need a
|
||||
// moment to settle before outbound HTTP works reliably.
|
||||
bool sessionOk = false;
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
sessionOk = await HivemindService.verifySession();
|
||||
@@ -235,10 +169,9 @@ class VpnConnection extends ChangeNotifier {
|
||||
if (!sessionOk) {
|
||||
debugPrint('[VPN] Tunnel started but no server session — tearing down.');
|
||||
try {
|
||||
await _wireguard.stopVpn().timeout(const Duration(seconds: 3));
|
||||
await _vless.stopVless();
|
||||
} catch (_) {}
|
||||
_errorMessage = 'Session not confirmed by server.\n'
|
||||
'Please try again.';
|
||||
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
|
||||
_setStatus(VpnStatus.error, 'Session rejected');
|
||||
return false;
|
||||
}
|
||||
@@ -246,49 +179,17 @@ class VpnConnection extends ChangeNotifier {
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('WireGuard tunnel error: $e');
|
||||
final raw = e.toString();
|
||||
if (raw.contains('VPN_PERMISSION_REQUESTED')) {
|
||||
// Native plugin showed the VPN permission dialog — wait and retry
|
||||
_setStatus(VpnStatus.connecting, 'Waiting for permission…');
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// Retry the tunnel start once the user has had time to grant permission
|
||||
try {
|
||||
await _wireguard.startVpn(
|
||||
serverAddress: AppConfig.serverEndpoint,
|
||||
wgQuickConfig: configText,
|
||||
providerBundleIdentifier: 'com.paladinvpn.app.WireGuardProvider',
|
||||
);
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
return true;
|
||||
} catch (retryError) {
|
||||
debugPrint('WireGuard retry error: $retryError');
|
||||
_errorMessage = 'VPN permission was denied.\n'
|
||||
'Please grant the VPN permission and try again.';
|
||||
_setStatus(VpnStatus.error, 'Permission denied');
|
||||
return false;
|
||||
}
|
||||
} else if (raw.contains('Permission') || raw.contains('permissions')) {
|
||||
_errorMessage = 'VPN permission was denied.\n'
|
||||
'Please grant the VPN permission and try again.';
|
||||
_setStatus(VpnStatus.error, 'Permission denied');
|
||||
} else if (raw.contains('NameInvalid') || raw.contains('invalid')) {
|
||||
_errorMessage = 'The WireGuard configuration is invalid.\n'
|
||||
'Please contact support.';
|
||||
_setStatus(VpnStatus.error, 'Bad config');
|
||||
} else {
|
||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||
_setStatus(VpnStatus.error, 'Tunnel error');
|
||||
}
|
||||
debugPrint('[VPN] VLESS tunnel error: $e');
|
||||
_errorMessage = 'Tunnel failed to start.\nTry reconnecting.';
|
||||
_setStatus(VpnStatus.error, 'Connection failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disconnect ────────────────────────────────────────────────────────────
|
||||
// Tears down the active tunnel and returns Android to normal networking.
|
||||
Future<void> disconnect() async {
|
||||
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
|
||||
return; // Already disconnecting or disconnected — don't double-fire
|
||||
return;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.disconnecting, 'Tearing down…');
|
||||
@@ -299,39 +200,27 @@ class VpnConnection extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the VPN tunnel with a hard timeout.
|
||||
// stopVpn() can hang indefinitely if the native Go backend is stuck —
|
||||
// we give it 5 seconds max, then force through.
|
||||
try {
|
||||
await _wireguard.stopVpn().timeout(
|
||||
await _vless.stopVless().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
debugPrint('[VPN] stopVpn() timed out — forcing disconnect state.');
|
||||
},
|
||||
onTimeout: () => debugPrint('[VPN] stopVless() timed out.'),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('WireGuard stop error: $e');
|
||||
debugPrint('[VPN] VLESS stop error: $e');
|
||||
}
|
||||
|
||||
// Clean up the peer from the server so we don't leave dead connections lying around.
|
||||
// Fire-and-forget with a short timeout — server cleanup failing should never
|
||||
// block the local disconnect from completing.
|
||||
HivemindService.disconnectAndCleanup();
|
||||
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
}
|
||||
|
||||
// ── Internal state updater ────────────────────────────────────────────────
|
||||
// Updates state and triggers a UI re-render (like calling setState() in Python/Qt)
|
||||
void _setStatus(VpnStatus s, String msg) {
|
||||
_status = s;
|
||||
_statusMessage = msg;
|
||||
notifyListeners(); // tells all listening widgets to rebuild
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stageSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user