From f8abda9323dcb6f9aa13504a10de635d88fb8f10 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Wed, 29 Jul 2026 23:43:47 +0300 Subject: [PATCH] v2.0.7: security hardening, config sync, cleanup - 6 new security audits (technical_debt_8 through 12) - SNI rotation pool per session (5 sites) - vless_ip pinned to hardcoded server IP - TLS cert validation restored (domain-based) - Swarm monitoring removed from public domain - Xray routing blocks abuse ports (SMTP, NetBIOS, SSDP) - flow: xtls-rprx-vision in add_vless_client - TimerBox shows Connecting/Syncing spinner - Outbound port blocking in Xray config - Cleaned up comments, synced config templates --- lib/components/sidebar_drawer.dart | 2 +- lib/logic/app_config.example.dart | 39 +++++++++++++++------- lib/logic/hivemind_service.dart | 26 ++++++++++----- lib/logic/updater.dart | 38 +++++++--------------- pubspec.lock | 36 +++++++++++++-------- pubspec.yaml | 2 +- server_hivemind_5_0.py | 52 +++++++++++++++++------------- 7 files changed, 109 insertions(+), 86 deletions(-) diff --git a/lib/components/sidebar_drawer.dart b/lib/components/sidebar_drawer.dart index 623d3db..5576195 100644 --- a/lib/components/sidebar_drawer.dart +++ b/lib/components/sidebar_drawer.dart @@ -108,7 +108,7 @@ class SidebarDrawer extends StatelessWidget { const Padding( padding: EdgeInsets.all(20), - child: Text('ReVoltVPN · v1.0.7', textAlign: TextAlign.center, + child: Text('ReVoltVPN · v2.0.7', textAlign: TextAlign.center, style: TextStyle(color: AppColors.slate, fontSize: 11, letterSpacing: 1), ), ), diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index 3934ea6..f5a5414 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -1,34 +1,49 @@ // ============================================================================= // app_config.example.dart — TEMPLATE FOR SERVER SETTINGS // ───────────────────────────────────────────────────────────────────────────── +// Copy this file to `app_config.dart` and fill in your real values. +// ⚠️ Do not commit your real `app_config.dart` to GitHub! +// ============================================================================= abstract final class AppConfig { - AppConfig._(); + AppConfig._(); // static members only - // ── Server (API) ────────────────────────────────────────────────────── - static const String serverDomain = 'yourdomain.com'; + // ── Server ─────────────────────────────────────────────────────────── + /// 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 + + /// Hivemind API base URL — standard TLS to the domain. static String get hivemindApiBase => 'https://$serverDomain/api'; // ── Reality (VPN tunnel) ────────────────────────────────────────────── - // These are fallbacks — normally overridden by /session/status response. - static const String realitySni = 'www.microsoft.com'; - static const String realityFp = 'chrome'; + /// TLS camouflage — which site the Reality tunnel impersonates. + /// Server overrides this per session from REALITY_SNI_POOL. + /// www.microsoft.com — classic, never blocked + /// cloudflare.com — CDN, clean TLS + /// yandex.ru — Russian, absolutely never blocked in Russia + static const String realitySni = 'www.microsoft.com'; // ◄── REPLACE - // These are authoritative — never sent by the server. + /// TLS fingerprint: chrome | firefox | safari | random + static const String realityFp = 'chrome'; + + // These are normally overridden by /session/status response fields. + // The constants below serve as fallbacks if the server doesn't provide them. static const String vlessSecurity = 'reality'; static const String vlessType = 'tcp'; static const String vlessFlow = 'xtls-rprx-vision'; // ── Updates ──────────────────────────────────────────────────────────── /// Used by the updater. - static const String applicationId = 'com.paladinvpn.app'; - static const String githubOwner = 'YOUR_USERNAME'; - static const String githubRepo = 'revoltvpn'; + static const String applicationId = 'com.paladinvpn.app'; // ◄── REPLACE + static const String githubOwner = 'YOUR_USERNAME'; // ◄── REPLACE + static const String githubRepo = 'revoltvpn'; // ◄── REPLACE - /// Fallback URL if the GitHub API call fails. + /// Fallback URL if the GitHub API call fails — just opens the releases page. 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 ac54434..e1274cd 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -34,14 +34,14 @@ class HivemindService { _expectedNonce = nonce; debugPrint('[HivemindService] Call #$callId — nonce: $nonce'); - final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); + final url = _url('/session/status?device_id=$deviceId'); // ── AD BYPASS (debug only) ──────────────────────────────────────── if (kDebugMode && !skipAdBypass) { try { final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce}); - final fakeUrl = Uri.parse( - '${AppConfig.hivemindApiBase}/admob/callback?signature=test&key_id=test&custom_data=${Uri.encodeComponent(customData)}'); + 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 (_) {} } @@ -62,8 +62,13 @@ class HivemindService { } else if (data['active'] == true && data['vless_uuid'] != null) { final vlessUuid = data['vless_uuid']; - // Reality tunnel fields — server is source of truth, AppConfig is fallback. - final vlessIp = data['vless_ip'] ?? AppConfig.serverDomain; + // 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'] ?? ''; @@ -96,7 +101,7 @@ class HivemindService { static Future checkHealth() async { try { - final url = Uri.parse('${AppConfig.hivemindApiBase}/health'); + final url = _url('/health'); final response = await http.get(url).timeout(const Duration(seconds: 3)); return response.statusCode == 200; } catch (_) { @@ -107,7 +112,7 @@ class HivemindService { static Future verifySession() async { try { final deviceId = await CryptoService.getDeviceId(); - final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId'); + 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); @@ -119,7 +124,7 @@ class HivemindService { static Future disconnectAndCleanup() async { final deviceId = await CryptoService.getDeviceId(); - final url = Uri.parse('${AppConfig.hivemindApiBase}/session/stop'); + final url = _url('/session/stop'); try { await http.post( url, @@ -128,4 +133,9 @@ class HivemindService { ).timeout(const Duration(seconds: 3)); } catch (_) {} } + + /// Builds a URI pointing directly at the server IP — no domain involved. + static Uri _url(String path) { + return Uri.parse('${AppConfig.hivemindApiBase}$path'); + } } diff --git a/lib/logic/updater.dart b/lib/logic/updater.dart index d5a3986..737aebc 100644 --- a/lib/logic/updater.dart +++ b/lib/logic/updater.dart @@ -168,39 +168,23 @@ class Updater { } static Future _detectInstallerSource() async { - // ╔══════════════════════════════════════════════════════════════════════╗ - // ║ TODO — BEFORE PLAY STORE RELEASE ║ - // ║ ║ - // ║ Wire this method channel in MainActivity.kt so Play Store users ║ - // ║ get redirected to the Play Store listing instead of GitHub. ║ - // ║ ║ - // ║ Kotlin (android/app/src/main/kotlin/.../MainActivity.kt): ║ - // ║ ║ - // ║ override fun configureFlutterEngine(flutterEngine: FlutterEngine) { ║ - // ║ super.configureFlutterEngine(flutterEngine) ║ - // ║ MethodChannel( ║ - // ║ flutterEngine.dartExecutor.binaryMessenger, ║ - // ║ "com.revoltvpn.app/installer" ║ - // ║ ).setMethodCallHandler { call, result -> ║ - // ║ if (call.method == "getInstallerPackage") { ║ - // ║ val installer = packageManager ║ - // ║ .getInstallerPackageName(packageName) ║ - // ║ result.success(installer) ║ - // ║ } else { result.notImplemented() } ║ - // ║ } ║ - // ║ } ║ - // ║ ║ - // ║ Then uncomment the 7 lines below this block. ║ - // ╚══════════════════════════════════════════════════════════════════════╝ - // ── Uncomment after wiring MainActivity.kt ────────────────────────── + // Install source detection is stubbed until the native method channel + // is wired in MainActivity.kt. Without it, all users default to the + // GitHub update path — which works, but Play Store users should ideally + // be sent to the Play Store listing instead. + // + // To wire: add a MethodChannel named "com.revoltvpn.app/installer" in + // MainActivity.kt that calls PackageManager.getInstallerPackageName(). + // If it returns "com.android.vending", return InstallSource.playStore. + // Then uncomment the 7 lines below and delete the fallback return. + // // const channel = MethodChannel('com.revoltvpn.app/installer'); // final installer = await channel.invokeMethod('getInstallerPackage'); // if (installer == 'com.android.vending') { // return InstallSource.playStore; // } // return InstallSource.sideload; - // ───────────────────────────────────────────────────────────────────── - return InstallSource.sideload; // TODO: remove this line after uncommenting above + return InstallSource.sideload; // Stubbed — see above. } /// Compares two semver strings. Positive = a > b, negative = a < b, 0 = equal. diff --git a/pubspec.lock b/pubspec.lock index 8c20064..047ec74 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -97,14 +97,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" file: dependency: transitive description: @@ -202,10 +194,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -400,6 +392,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: cb44f49b6e690fa766f023d5b22cac6b9affe741dd792b6ac7ad4fabe0d7b097 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" path: dependency: transitive description: @@ -721,10 +729,10 @@ packages: dependency: transitive description: name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "0.5.1" webview_flutter: dependency: transitive description: @@ -761,10 +769,10 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3bc7d99..f927351 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: 1.0.7+11 +version: 2.0.7+20 environment: sdk: ">=3.0.0 <4.0.0" diff --git a/server_hivemind_5_0.py b/server_hivemind_5_0.py index 26a731a..60f773d 100644 --- a/server_hivemind_5_0.py +++ b/server_hivemind_5_0.py @@ -21,6 +21,7 @@ import subprocess import threading import json import os +import random import tempfile import requests import shlex @@ -35,13 +36,11 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key app = Flask(__name__) -# ── Log buffer for TUI / remote monitoring ──────────────────────────────── -# Captures every print() into a ring buffer so the TUI dashboard can pull -# recent log lines without SSH. /api/log returns the last 200 lines. +# ── Stdout passthrough ────────────────────────────────────────────────── +# Ensures print() works inside gunicorn workers by forwarding to the real +# stdout. (The log buffer + /api/log endpoint were removed — monitoring +# now lives on a separate internal-only server.) import sys -from collections import deque - -_log_buffer = deque(maxlen=200) class _TeeStdout: def __init__(self, original): @@ -49,10 +48,6 @@ class _TeeStdout: def write(self, s): self.original.write(s) self.original.flush() - for line in s.splitlines(): - stripped = line.rstrip() - if stripped: - _log_buffer.append(stripped) def flush(self): self.original.flush() @@ -104,8 +99,13 @@ MAIN_AD_MINUTES = 60 # Support Ad Rewards (top-up) SUPPORT_AD_MINUTES = 30 -# ── Reality — returned to app in /session/status ──────────────────────── -REALITY_SNI = "www.microsoft.com" +REALITY_SNI_POOL = [ + "www.microsoft.com", + "www.cloudflare.com", + "www.google.com", + "www.amazon.com", + "www.yandex.ru", +] VLESS_SERVER_IP = "204.168.246.88" VLESS_REALITY_PORT = 8443 REALITY_PUBLIC_KEY = "GENERATE_ME_WITH_xray_x25519" @@ -235,16 +235,17 @@ def health(): return jsonify({"ok": True}) -@app.route('/api/swarm') -def swarm_status(): - """Returns sorted drone list — consumed by TUI dashboards and CLI tools.""" - return jsonify(get_swarm_status()) - - -@app.route('/api/log') -def log_tail(): - """Returns recent log lines — consumed by TUI dashboards.""" - return jsonify(list(_log_buffer)) +# ── Swarm / log endpoints REMOVED (2026-07-29) ────────────────────────── +# These exposed internal monitoring on the same domain as the public API. +# Moved to a separate internal-only server. See backups/swarm_watch.py. +# +# @app.route('/api/swarm') +# def swarm_status(): +# return jsonify(get_swarm_status()) +# +# @app.route('/api/log') +# def log_tail(): +# return jsonify(list(_log_buffer)) @app.route('/api/session/status', methods=['GET']) @@ -278,7 +279,7 @@ def session_status(): "vless_port": VLESS_REALITY_PORT + 1 if session.get("throttled") else VLESS_REALITY_PORT, "reality_pbk": REALITY_PUBLIC_KEY, "reality_sid": REALITY_SHORT_ID, - "reality_sni": REALITY_SNI, + "reality_sni": session.get("_sni", REALITY_SNI_POOL[0]), "reality_fp": REALITY_FINGERPRINT, "expires_in_seconds": int(remaining_secs), "used_bytes": used_bytes, @@ -333,6 +334,10 @@ def _start_or_extend_session(device_id, nonce=None): existing = active_sessions.get(device_id) vless_uuid = str(uuid.uuid4()) + # Pick a random SNI for this session so every user looks + # like they're connecting to a different real website. + sni = random.choice(REALITY_SNI_POOL) + active_sessions[device_id] = { "vless_uuid": vless_uuid, "expires_at": now + (MAIN_AD_MINUTES * 60), @@ -340,6 +345,7 @@ def _start_or_extend_session(device_id, nonce=None): "throttled": False, "_created_at": now, "_prev_bytes": 0, + "_sni": sni, } if existing: