From 4fde4a92175eb93da11c641f0caa1cbf21e0dd0a Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sun, 2 Aug 2026 14:00:38 +0300 Subject: [PATCH] v3.0.0: domain-free client, bootstrap tunnel, tunnel-internal API --- .gitignore | 1 + CHANGELOG.md | 70 ++++++-------- lib/logic/app_config.example.dart | 30 ++++-- lib/logic/hivemind_service.dart | 11 ++- lib/logic/vpn_connection.dart | 149 ++++++++++++++++++++---------- pubspec.yaml | 2 +- 6 files changed, 158 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index d10bd39..7f8da81 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ scraped.zip hivemind.zip XRAY_AUDIT.md TECHNICAL_DEBT.md +DOCUMENTATION-ARCHITECTURE-V2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce760d..483ea47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,52 +7,36 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH ## [3.0.0] — 2026-08 -### Protocol — TCP → XHTTP migration -- **Transport changed from TCP to XHTTP** (H2 stream-up with Reality). - XTLS-Vision (`xtls-rprx-vision`) removed — it only applies to TCP+TLS/Reality, - not XHTTP. XHTTP provides H2 multiplexing, CDN passthrough, and path-based - hiding behind `/revolt`. -- **`flow` field removed from VLESS client config.** Xray falls back to standard - TLS proxying inside the XHTTP tunnel — correct and intentional per official - VLESS inbound docs. +### Architecture — domain-free client +- **Zero domain strings in APK.** RKN has nothing to DNS-block. API calls go + through the Reality tunnel to `10.254.254.1:5000`, redirected to Flask by + Xray's `api` freedom outbound (`redirect: 127.0.0.1:5000`). +- **Bootstrap tunnel.** Bundled Reality config in APK → connect → fetch real + per-session VLESS URL from inside the tunnel → reconnect. Bootstrap client + permanently in `xray_config_reality.json`, restricted by Xray routing to + internal API only. +- `serverDomain` removed from `AppConfig`. `fetchConfigThroughTunnel()` + replaces `fetchConfigWithPolling()`. `connect()` rewritten. -### Fixed — critical server bug -- **Client hot-reload was broken.** `xray.py` called `xray api adi` (add inbounds, - zero arguments) after writing `config.json`. This was a no-op — Xray never - learned about new VLESS clients. Every session created since the modular - rewrite was silently rejected at the VLESS layer. - Fixed: `add_client()` now writes a temp JSON and calls `xray api adu` - (add users). `remove_client()` calls `xray api rmu -tag=X email`. - Verified against `XTLS/Xray-core` HandlerService source. +### Protocol — TCP to XHTTP +- Transport: TCP → XHTTP (H2 stream-up with Reality). No more XTLS-Vision, + no `flow` field. Path-hidden behind `/revolt`. -### Fixed — client -- **Missing XHTTP path in VLESS URL.** Server requires `path: "/revolt"` in - `xhttpSettings` but the VLESS URL never included a `path` parameter. - Fixed: `hivemind_service.dart` reads `xhttp_path` from `/session/status`, - falls back to `AppConfig.vlessPath`. -- **`providerBundleIdentifier` mismatch.** Was `com.revoltvpn.app` — must match - Android `applicationId` (`com.paladinvpn.app`) for VPN service registration. +### Fixed +- Client hot-reload was silently broken — `xray api adi` no-op. Fixed: + `adu`/`rmu` commands per Xray HandlerService spec. +- Missing XHTTP `path` param in VLESS URL — now reads `xhttp_path` from + `/session/status`. +- `providerBundleIdentifier` was `com.revoltvpn.app` — must match + `com.paladinvpn.app` for VPN service registration. +- Reality keypair generated (was placeholder). Throttle `uplinkOnly`/`downlinkOnly` + corrected (seconds, not bytes/sec). Fallback rate limits removed (fingerprint). -### Fixed — server config -- **Reality keypair generated.** Replaced `GENERATE_ME_WITH_xray_x25519` - placeholders in both `config.py` and `xray_config_reality.json` with a real - X25519 keypair. Private key stays on server; public key sent to clients. -- **Throttle policy corrected.** `uplinkOnly`/`downlinkOnly` were set to - `187500` — that's 52 hours, not 1.5 Mbps. These fields are **seconds** - (idle timeouts), not bandwidth rate limiters (per official Policy docs). - Set to defaults (2s / 5s). Real 1.5 Mbps cap requires Linux `tc`. -- **Fallback rate limits removed.** Live Reality docs warn fallback limiting - *"is itself a fingerprint and is not recommended."* Ports are firewalled - so fallback traffic is impossible anyway. Comment added for future reference. - -### Added -- **`xhttp_path` in `/session/status` response.** Server now sends the XHTTP - path to clients, same pattern as `reality_pbk`/`reality_sid`/`reality_sni`. - `VLESS_XHTTP_PATH` constant in `config.py` is the single source of truth. -- **Updated deployment guide** (`SERVER_DEPLOYMENT.md`) — full step-by-step - for XHTTP+Reality, verified against live Xray docs at xtls.github.io. -- **Updated cheatsheet** (`scraped/VLESS_XRAY_CHEATSHEET.md`) — all sections - rewritten for XHTTP. Old TCP/Vision info replaced. +### Server +- `api` outbound + 3 routing rules in `xray_config_reality.json` (bootstrap + isolation + API routing). Permanent bootstrap client. Hivemind unchanged. +- `xhttp_path` in `/session/status` response. +- Full deployment guide: `SERVER_DEPLOYMENT.md`. --- diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index 8c8d48e..8198e12 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -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 -} \ No newline at end of file +} diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index d9db259..405bac2 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -18,10 +18,14 @@ class HivemindService { _expectedNonce = nonce; } - static Future 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 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'); diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 8fc0174..506d19d 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -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 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 disconnect({bool skipCleanup = false}) async { @@ -256,6 +294,17 @@ class VpnConnection extends ChangeNotifier { notifyListeners(); } + Future _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(); diff --git a/pubspec.yaml b/pubspec.yaml index f927351..2c5e001 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 2.0.7+20 +version: 3.0.0+21 environment: sdk: ">=3.0.0 <4.0.0"