diff --git a/.gitignore b/.gitignore index a65f31c..8e36791 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,5 @@ server_full_picture_2.txt hivemind_backup.py UI_POSITIONING.md SERVER_SETUP.md +REALITY_MIGRATION_PLAN.md +xray_config_reality.json diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index f61d857..c90498f 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -78,7 +78,7 @@ class _ConnectButtonState extends State setState(() => _busy = true); try { - final adWatched = await ad.showAd('main_ad'); + final adWatched = await ad.showAd('main'); if (!adWatched && AdManager.adsEnabled) { setState(() => _busy = false); return; diff --git a/lib/components/status_bar.dart b/lib/components/status_bar.dart index 3faaf2f..e6d5ac3 100644 --- a/lib/components/status_bar.dart +++ b/lib/components/status_bar.dart @@ -14,7 +14,7 @@ class StatusBar extends StatelessWidget { builder: (context, vpn, timer, _) { final isConnected = vpn.status == VpnStatus.connected; final isConnecting = vpn.status == VpnStatus.connecting; - final serverHealthy = isConnected && timer.hasSyncedOnce; + final serverHealthy = vpn.serverReachable || (isConnected && timer.hasSyncedOnce); return Container( margin: const EdgeInsets.symmetric(horizontal: 24), @@ -55,11 +55,9 @@ class StatusBar extends StatelessWidget { // ── Server health dot (right) ──────────────────────────── OnlineDot( isOnline: serverHealthy, - label: isConnected && !timer.hasSyncedOnce - ? 'Syncing…' - : serverHealthy - ? 'Online' - : 'Offline', + label: isConnected + ? (timer.hasSyncedOnce ? 'Online' : 'Syncing…') + : (vpn.serverReachable ? 'Ready' : 'Offline'), ), ], ), diff --git a/lib/components/timer_box.dart b/lib/components/timer_box.dart index db6bb54..6dd17e7 100644 --- a/lib/components/timer_box.dart +++ b/lib/components/timer_box.dart @@ -24,6 +24,12 @@ class TimerBox extends StatelessWidget { fontWeight: FontWeight.w700, color: AppColors.textWhite, letterSpacing: 2, + shadows: [ + Shadow( + color: AppColors.accent50, + blurRadius: 16, + ), + ], ), ), ); diff --git a/lib/logic/app_config.example.dart b/lib/logic/app_config.example.dart index c6d75bb..29a8c99 100644 --- a/lib/logic/app_config.example.dart +++ b/lib/logic/app_config.example.dart @@ -3,22 +3,29 @@ // ───────────────────────────────────────────────────────────────────────────── // Rename this file to `app_config.dart` and fill in your real values. // Do not commit your real `app_config.dart` to GitHub! +// +// Most Reality tunnel fields (vless_ip, vless_port, reality_pbk, reality_sid, +// reality_sni, reality_fp) come from the server's /session/status response. +// The constants below are fallbacks — the server is the source of truth. // ============================================================================= abstract final class AppConfig { AppConfig._(); - // ── Server ───────────────────────────────────────────────────────────── - static const String serverDomain = 'yourdomain.com'; // REPLACE - - static const String vlessPath = '/tunnel'; // REPLACE if different + // ── Server (API) ────────────────────────────────────────────────────── + static const String serverDomain = 'yourdomain.com'; // ◄── REPLACE static String get hivemindApiBase => 'https://$serverDomain/api'; - // ── VLESS ────────────────────────────────────────────────────────────── - static const int serverPort = 443; - static const String vlessSecurity = 'tls'; - static const String vlessType = 'ws'; + // ── Reality (VPN tunnel) ────────────────────────────────────────────── + // These are fallbacks — normally overridden by /session/status response. + static const String realitySni = 'www.microsoft.com'; + static const String realityFp = 'chrome'; + + // These are authoritative — never sent by the server. + static const String vlessSecurity = 'reality'; + static const String vlessType = 'tcp'; + static const String vlessFlow = 'xtls-rprx-vision'; // ── AdMob ────────────────────────────────────────────────────────────── static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index d2a3448..ac54434 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -61,11 +61,23 @@ class HivemindService { debugPrint('[HivemindService] Nonce mismatch — retrying…'); } else if (data['active'] == true && data['vless_uuid'] != null) { final vlessUuid = data['vless_uuid']; - final vlessPath = data['vless_path'] ?? AppConfig.vlessPath; - final vlessUrl = 'vless://$vlessUuid@${AppConfig.serverDomain}:${AppConfig.serverPort}' - '?path=${Uri.encodeComponent(vlessPath)}' - '&security=${AppConfig.vlessSecurity}' + + // Reality tunnel fields — server is source of truth, AppConfig is fallback. + final vlessIp = data['vless_ip'] ?? AppConfig.serverDomain; + final vlessPort = data['vless_port'] ?? 8443; + final pbk = data['reality_pbk'] ?? ''; + final sid = data['reality_sid'] ?? ''; + final sni = data['reality_sni'] ?? AppConfig.realitySni; + final fp = data['reality_fp'] ?? AppConfig.realityFp; + + final vlessUrl = 'vless://$vlessUuid@$vlessIp:$vlessPort' + '?security=${AppConfig.vlessSecurity}' '&type=${AppConfig.vlessType}' + '&flow=${AppConfig.vlessFlow}' + '&pbk=${Uri.encodeComponent(pbk)}' + '&sni=${Uri.encodeComponent(sni)}' + '&sid=${Uri.encodeComponent(sid)}' + '&fp=${Uri.encodeComponent(fp)}' '#ReVoltVPN'; _expectedNonce = null; return vlessUrl; @@ -82,6 +94,16 @@ class HivemindService { throw Exception('Session not activated. Server callback may have timed out.'); } + static Future checkHealth() async { + try { + final url = Uri.parse('${AppConfig.hivemindApiBase}/health'); + final response = await http.get(url).timeout(const Duration(seconds: 3)); + return response.statusCode == 200; + } catch (_) { + return false; + } + } + static Future verifySession() async { try { final deviceId = await CryptoService.getDeviceId(); diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index a30652d..1fd88b6 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -14,7 +14,6 @@ class SessionTimer extends ChangeNotifier { final VpnConnection vpnConnection; int _remainingSeconds = 0; - int _quotaBytes = 0; int _usedBytes = 0; bool _hasSyncedOnce = false; @@ -22,9 +21,10 @@ class SessionTimer extends ChangeNotifier { bool _isDisconnecting = false; bool _userInitiatedStop = false; - String? _currentPath; bool _isThrottled = false; - bool _pathSwitchInProgress = false; + + int? _currentPort; + bool _portSwitchInProgress = false; static const int _maxConsecutiveFailures = 3; @@ -48,9 +48,7 @@ class SessionTimer extends ChangeNotifier { bool get isExpired => _remainingSeconds <= 0 && !isRunning; bool get hasSyncedOnce => _hasSyncedOnce; - int get quotaBytes => _quotaBytes; int get usedBytes => _usedBytes; - int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0; double get currentSpeedKBps => _currentSpeedKBps; String get formatted { @@ -60,27 +58,6 @@ class SessionTimer extends ChangeNotifier { return '$h:$m:$s'; } - String get formattedDataRemaining { - if (_quotaBytes == 0) return '0.00 GB'; - final gb = remainingBytes / (1024 * 1024 * 1024); - return '${gb.toStringAsFixed(2)} GB'; - } - - String get formattedDataUsed { - if (_usedBytes == 0) return '0.00 MB'; - if (_usedBytes > 1024 * 1024 * 1024) { - final gb = _usedBytes / (1024 * 1024 * 1024); - return '${gb.toStringAsFixed(2)} GB'; - } - final mb = _usedBytes / (1024 * 1024); - return '${mb.toStringAsFixed(2)} MB'; - } - - double get progress { - if (_quotaBytes == 0) return 0.0; - return (_quotaBytes - _usedBytes) / _quotaBytes; - } - // Lifecycle /// Fires whenever VpnConnection state changes. @@ -113,7 +90,6 @@ class SessionTimer extends ChangeNotifier { Future start(String adType) async { _remainingSeconds = 0; - _quotaBytes = 0; _usedBytes = 0; _lastUsedBytes = 0; _currentSpeedKBps = 0.0; @@ -123,6 +99,8 @@ class SessionTimer extends ChangeNotifier { _offlineSeconds = 0; _isDisconnecting = false; _userInitiatedStop = false; + _currentPort = null; + _portSwitchInProgress = false; _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), _tick); @@ -227,7 +205,6 @@ class SessionTimer extends ChangeNotifier { } _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; - _quotaBytes = data['quota_bytes'] ?? _quotaBytes; _usedBytes = data['used_bytes'] ?? _usedBytes; @@ -240,27 +217,27 @@ class SessionTimer extends ChangeNotifier { _lastUsedBytes = _usedBytes; - // ── Detect path changes (throttle engage / disengage) ────── - final serverPath = data['vless_path'] as String?; - if (serverPath != null && - _currentPath != null && - serverPath != _currentPath && - !_pathSwitchInProgress) { - debugPrint('[Timer] Path changed $_currentPath → $serverPath — reconnecting…'); - _currentPath = serverPath; + // ── Detect port change (throttle engage / disengage) ────── + final serverPort = data['vless_port']; + if (serverPort != null && + _currentPort != null && + serverPort != _currentPort && + !_portSwitchInProgress) { + debugPrint('[Timer] Port changed $_currentPort → $serverPort — reconnecting…'); + _currentPort = serverPort; _isThrottled = data['is_throttled'] ?? false; - _pathSwitchInProgress = true; + _portSwitchInProgress = true; _timer?.cancel(); - _isDisconnecting = true; // block _stopInternal during relocation + _isDisconnecting = true; await vpnConnection.disconnect(skipCleanup: true); await vpnConnection.connect(skipAdBypass: true, quickReconnect: true); _resumeTicking(); - _pathSwitchInProgress = false; + _portSwitchInProgress = false; return; } - _currentPath = serverPath; + _currentPort = serverPort; _isThrottled = data['is_throttled'] ?? false; _consecutiveFailures = 0; diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 7636c42..7f2c66b 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -26,6 +26,11 @@ class VpnConnection extends ChangeNotifier { bool _isStartupRestoration = false; bool get isStartupRestoration => _isStartupRestoration; + bool _serverReachable = false; + bool get serverReachable => _serverReachable; + + Timer? _healthTimer; + late final FlutterVless _vless; bool _initialized = false; @@ -53,6 +58,10 @@ class VpnConnection extends ChangeNotifier { debugPrint('[VPN] VLESS init error (expected on emulator): $e'); } + // Periodic server health check + _checkHealth(); + _healthTimer = Timer.periodic(const Duration(seconds: 30), (_) => _checkHealth()); + // Check if VPN was already running (app killed and reopened) try { final coreVersion = await _vless.getCoreVersion(); @@ -62,9 +71,6 @@ class VpnConnection extends ChangeNotifier { if (delay > 0) { _isStartupRestoration = true; _setStatus(VpnStatus.connected, 'Secured'); - Future.delayed(const Duration(seconds: 5), () { - _isStartupRestoration = false; - }); } } catch (_) {} } @@ -75,12 +81,14 @@ class VpnConnection extends ChangeNotifier { _setStatus(VpnStatus.connected, 'Secured'); break; case VlessConnectionState.disconnected: + _isStartupRestoration = false; _setStatus(VpnStatus.disconnected, 'Tap to connect'); break; case VlessConnectionState.connecting: _setStatus(VpnStatus.connecting, 'Establishing tunnel…'); break; case VlessConnectionState.disconnecting: + _isStartupRestoration = false; _setStatus(VpnStatus.disconnecting, 'Tearing down…'); break; case VlessConnectionState.unknown: @@ -234,8 +242,14 @@ class VpnConnection extends ChangeNotifier { notifyListeners(); } + Future _checkHealth() async { + _serverReachable = await HivemindService.checkHealth(); + notifyListeners(); + } + @override void dispose() { + _healthTimer?.cancel(); if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { try { _vless.stopVless(); diff --git a/lib/main.dart b/lib/main.dart index 1d11ac8..8e247c4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; @@ -22,6 +23,15 @@ void main() async { systemNavigationBarIconBrightness: Brightness.light, )); + FlutterError.onError = (details) { + debugPrint('[FlutterError] ${details.exception}'); + }; + + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('[PlatformError] $error\n$stack'); + return true; + }; + await ConsentManager.requestConsentIfNeeded(); await MobileAds.instance.initialize(); diff --git a/nginx_hivemind_reality.conf b/nginx_hivemind_reality.conf new file mode 100644 index 0000000..8f9cf1f --- /dev/null +++ b/nginx_hivemind_reality.conf @@ -0,0 +1,41 @@ +# nginx config for ReVoltVPN — Reality era +# Path: /etc/nginx/sites-available/hivemind +# +# After Reality migration: +# - /tunnel and /tunnel-throttled locations are REMOVED +# - Xray Reality handles the VPN tunnel directly on port 8443 +# - nginx only serves the API (proxied to Flask) and any static website + +server { + listen 443 ssl http2; + server_name getrevolt.app; # ◄── CHANGE ME to your domain + + # ── SSL (Let's Encrypt) ────────────────────────────────────────────── + ssl_certificate /etc/letsencrypt/live/getrevolt.app/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/getrevolt.app/privkey.pem; + + # ── API proxy to Hivemind (Flask) ──────────────────────────────────── + location /api/ { + proxy_pass http://127.0.0.1:5000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } + + # ── Static website (optional landing page) ─────────────────────────── + location / { + root /var/www/revolt; + index index.html; + try_files $uri $uri/ =404; + } +} + +# Redirect HTTP → HTTPS +server { + listen 80; + server_name getrevolt.app; + return 301 https://$host$request_uri; +} diff --git a/server_hivemind_5_0.py b/server_hivemind_5_0.py index 6556c58..e327096 100644 --- a/server_hivemind_5_0.py +++ b/server_hivemind_5_0.py @@ -1,19 +1,18 @@ #!/usr/bin/env python3 """ Hivemind VPN Session Manager -Manages time + data quotas for VLESS clients via ad rewards. +Manages VLESS Reality sessions via ad rewards — 60 min, throttle at 4 GB. Designed for Android-only clients. === TABLE OF CONTENTS =========================================================== - LINE SECTION - 37 Configuration — ad rewards, Xray paths - 75 State & Cache — sessions dict, stats failure counter - 97 Flask Routes — /admob/callback, /session/status, /session/stop - 202 Session Logic — _start_or_extend_session (create / replace) - 270 AdMob SSV Verification — Google ECDSA signature checks - 355 Management Loop — expiry + data-quota watchdog (60 s tick) - 403 Low-Level Plumbing — add/remove users, Xray stats - 551 Entry Point — daemon thread + gunicorn bootstrap + Configuration — ad rewards, Reality constants, Xray paths + State & Cache — sessions dict, stats failure counter + Flask Routes — /admob/callback, /session/status, /session/stop + Session Logic — _start_or_extend_session, _extend_session + AdMob SSV Verification — Google ECDSA signature checks + Management Loop — expiry + throttle watchdog (60 s tick) + Low-Level Plumbing — add/remove users, Xray stats + Entry Point — daemon thread + gunicorn bootstrap ================================================================================ """ @@ -77,100 +76,25 @@ CHECK_INTERVAL_SECONDS = 60 # Checking every 1 minute is extremely safe and # Main Ad Rewards MAIN_AD_MINUTES = 60 -MAIN_AD_BYTES = 10 * 1024 * 1024 * 1024 # 10 GB (generous — no mobile device hits this in 1 hr) # Support Ad Rewards (top-up) SUPPORT_AD_MINUTES = 30 -SUPPORT_AD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB -# VLESS server info — returned to app in /session/status -VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain -VLESS_PATH = "/tunnel" +# ── Reality — returned to app in /session/status ──────────────────────── +REALITY_SNI = "www.microsoft.com" +VLESS_SERVER_IP = "204.168.246.88" +VLESS_REALITY_PORT = 8443 +REALITY_PUBLIC_KEY = "GENERATE_ME_WITH_xray_x25519" +REALITY_SHORT_ID = "abc123" +REALITY_FINGERPRINT = "chrome" -# ── Xray config.json template ──────────────────────────────────────────────── -# Copy this to /usr/local/etc/xray/config.json on the server. -# -# • Two VLESS inbounds: vless-normal (full speed) + vless-throttled (1.5 Mbps via nginx) -# • nginx routes /tunnel → :8443 and /tunnel-throttled → :8444 with limit_rate 187k -# • Hivemind moves users between inbounds at 4 GB threshold -# -# { -# "log": { "loglevel": "warning" }, -# "inbounds": [ -# { -# "tag": "vless-normal", -# "port": 8443, -# "listen": "127.0.0.1", -# "protocol": "vless", -# "settings": { "clients": [], "decryption": "none" }, -# "streamSettings": { "method": "ws", "wsSettings": { "path": "/tunnel" } }, -# "sniffing": { "enabled": true, "destOverride": ["http", "tls"] } -# }, -# { -# "tag": "vless-throttled", -# "port": 8444, -# "listen": "127.0.0.1", -# "protocol": "vless", -# "settings": { "clients": [], "decryption": "none" }, -# "streamSettings": { "method": "ws", "wsSettings": { "path": "/tunnel" } }, -# "sniffing": { "enabled": true, "destOverride": ["http", "tls"] } -# } -# ], -# "outbounds": [ -# { -# "protocol": "freedom", -# "tag": "direct", -# "mux": { "enabled": true, "concurrency": 8 } -# } -# ], -# "api": { -# "tag": "api", -# "listen": "127.0.0.1:10085", -# "services": ["HandlerService", "StatsService"] -# }, -# "stats": {}, -# "policy": { -# "system": { -# "statsInboundUplink": true, -# "statsInboundDownlink": true -# }, -# "levels": { -# "0": { "statsUserUplink": true, "statsUserDownlink": true } -# } -# } -# } -# "outbounds": [ -# { -# "protocol": "freedom", -# "tag": "direct", -# "mux": { "enabled": true, "concurrency": 8 } -# } -# ], -# "api": { -# "tag": "api", -# "listen": "127.0.0.1:10085", -# "services": ["HandlerService", "StatsService"] -# }, -# "stats": {}, -# "policy": { -# "system": { -# "statsInboundUplink": true, -# "statsInboundDownlink": true -# }, -# "levels": { -# "0": { "statsUserUplink": true, "statsUserDownlink": true } -# } -# } -# } - -# Xray paths +# ── Xray paths ─────────────────────────────────────────────────────────── XRAY_CONFIG = "/usr/local/etc/xray/config.json" XRAY_API = "http://127.0.0.1:10085" -VLESS_INBOUND_TAG = "vless-in" # legacy — kept for backwards compat -VLESS_NORMAL_TAG = "vless-normal" # full-speed inbox -VLESS_THROTTLED_TAG = "vless-throttled" # 1.5 Mbps inbox +VLESS_REALITY_TAG = "vless-reality" +VLESS_REALITY_THROTTLED_TAG = "vless-reality-throttled" -THROTTLE_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB — throttle beyond this +THROTTLE_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB # AdMob SSV verification toggle # True = dev mode, accept any callback (no signature check) @@ -190,15 +114,6 @@ sessions_lock = threading.Lock() # Used to rate-limit log spam when the stats endpoint is unreachable. _xray_stats_failures = 0 -# Keyed by device_id (e.g. "esf-18fc2...") -# { -# "device_id": { -# "vless_uuid": "550e8400-...", -# "expires_at": 1716480000, -# "quota_bytes": 2147483648, -# "nonce": "a7f3b2-1716480000000", -# } -# } active_sessions = {} @@ -267,6 +182,11 @@ def admob_callback(): return "Internal Error", 500 +@app.route('/api/health') +def health(): + return jsonify({"ok": True}) + + @app.route('/api/session/status', methods=['GET']) def session_status(): """ @@ -287,21 +207,22 @@ def session_status(): stats = get_xray_stats() used_bytes = stats.get(device_id, {}).get("total_bytes", 0) - remaining_data = max(0, session["quota_bytes"] - used_bytes) remaining_secs = max(0, session["expires_at"] - time.time()) return jsonify({ "active": remaining_secs > 0, "vless_uuid": session["vless_uuid"], - "server_domain": VLESS_DOMAIN, - "server_port": 443, - "vless_path": VLESS_PATH, + "vless_ip": VLESS_SERVER_IP, + # NOTE: assumes throttled port = normal port + 1. If you change the + # throttled port in xray_config_reality.json, update this logic too. + "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_fp": REALITY_FINGERPRINT, "expires_in_seconds": int(remaining_secs), - "quota_bytes": session["quota_bytes"], "used_bytes": used_bytes, - "remaining_bytes": remaining_data, "nonce": session.get("nonce", ""), - "vless_path": "/tunnel-throttled" if session.get("throttled") else VLESS_PATH, "is_throttled": session.get("throttled", False), }) @@ -348,7 +269,6 @@ def _start_or_extend_session(device_id, nonce=None): active_sessions[device_id] = { "vless_uuid": vless_uuid, "expires_at": now + (MAIN_AD_MINUTES * 60), - "quota_bytes": MAIN_AD_BYTES, "nonce": nonce, "throttled": False, } @@ -364,10 +284,10 @@ def _start_or_extend_session(device_id, nonce=None): op, arg = xray_action[0], xray_action[1] if op == "replace": remove_vless_client(device_id) - add_vless_client(arg, device_id, inbound_tag=VLESS_NORMAL_TAG) + add_vless_client(arg, device_id, inbound_tag=VLESS_REALITY_TAG) return True, "Session active." elif op == "add": - add_vless_client(arg, device_id, inbound_tag=VLESS_NORMAL_TAG) + add_vless_client(arg, device_id, inbound_tag=VLESS_REALITY_TAG) return True, "Session active." else: raise RuntimeError( @@ -378,7 +298,7 @@ def _start_or_extend_session(device_id, nonce=None): def _extend_session(device_id, nonce=None): - """Top-up an EXISTING session — adds time + quota without replacing the UUID. + """Top-up an EXISTING session — adds time without replacing the UUID. Used by the 'Support us' rewarded ad. Does NOT touch the throttle state or move the user between inbounds. @@ -390,13 +310,9 @@ def _extend_session(device_id, nonce=None): return False, "No active session to extend" session["expires_at"] += SUPPORT_AD_MINUTES * 60 - session["quota_bytes"] += SUPPORT_AD_BYTES session["nonce"] = nonce - print(f"[SESSION] Extended for {device_id}: " - f"+{SUPPORT_AD_MINUTES} min, " - f"+{SUPPORT_AD_BYTES/1e9:.1f} GB, " - f"new quota={session['quota_bytes']/1e9:.1f} GB") + print(f"[SESSION] Extended for {device_id}: +{SUPPORT_AD_MINUTES} min") return True, "Session extended" @@ -487,7 +403,7 @@ def _verify_admob_signature(raw_query_string: str, signature_b64url: str, key_id # ============================================================================== -# MANAGEMENT LOOP (The Hivemind — expiry + data-quota watchdog) +# MANAGEMENT LOOP (The Hivemind — expiry + throttle watchdog) # ============================================================================== def management_loop(): print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.") @@ -497,21 +413,18 @@ def management_loop(): stats = get_xray_stats() now = time.time() - # ── Phase 1: decide what to do under the lock ──────────────── with sessions_lock: - to_remove = [] # (device_id,) — expired - to_throttle_add = [] # (device_id, uuid) — add to throttled box - to_throttle_cleanup = [] # (device_id,) — remove from normal box (delayed) + to_remove = [] + to_throttle_add = [] + to_throttle_cleanup = [] for device_id, session in list(active_sessions.items()): - # ── 1. Check expiry ─────────────────────────────────────── if now > session["expires_at"]: print(f"[HIVEMIND] Session expired for {device_id}.") to_remove.append(device_id) continue - # ── 2. Check throttle threshold ──────────────────────────── used_bytes = stats.get(device_id, {}).get("total_bytes", 0) just_throttled = False @@ -521,9 +434,6 @@ def management_loop(): just_throttled = True to_throttle_add.append((device_id, session["vless_uuid"])) - # ── 3. Delayed cleanup: remove from normal box ───────────── - # Only on the NEXT tick — app needs time to see the path - # change and gracefully reconnect first. if (session.get("throttled") and not just_throttled and not session.get("_cleaned_normal")): to_throttle_cleanup.append(device_id) @@ -532,17 +442,14 @@ def management_loop(): for device_id in to_remove: del active_sessions[device_id] - # ── Phase 2: file I/O + subprocess OUTSIDE the lock ─────── for device_id in to_remove: remove_vless_client(device_id) - # Add to throttled box FIRST — app needs the UUID there to reconnect for device_id, uuid in to_throttle_add: - add_vless_client(uuid, device_id, inbound_tag=VLESS_THROTTLED_TAG) + add_vless_client(uuid, device_id, level=1, inbound_tag=VLESS_REALITY_THROTTLED_TAG) - # Then clean up the normal box — app already reconnected by now for device_id in to_throttle_cleanup: - remove_vless_client(device_id, inbound_tag=VLESS_NORMAL_TAG) + remove_vless_client_from(device_id, VLESS_REALITY_TAG) except Exception as e: print(f"[HIVEMIND] Unhandled error: {e}") @@ -575,11 +482,11 @@ def run_cmd(cmd, ignore_errors=False): return None -def add_vless_client(uuid, email, level=0, inbound_tag=VLESS_NORMAL_TAG): +def add_vless_client(uuid, email, level=0, inbound_tag=VLESS_REALITY_TAG): """Add a client UUID to the Xray inbound config and hot-reload. level -- Xray policy level for bandwidth limits: 0 = default. - inbound_tag -- which inbound to add the client to (VLESS_NORMAL_TAG or VLESS_THROTTLED_TAG). + inbound_tag -- which inbound to add the client to (default: VLESS_REALITY_TAG). """ with open(XRAY_CONFIG, 'r') as f: config = json.load(f) @@ -598,19 +505,13 @@ def add_vless_client(uuid, email, level=0, inbound_tag=VLESS_NORMAL_TAG): print(f"[VLESS] User added to {inbound_tag}: {email[:16]}...") -def remove_vless_client(email, inbound_tag=None): - """Remove a client from the Xray inbound config and hot-reload. - - inbound_tag -- if provided, only remove from that inbound. - if None, remove from ALL VLESS inbounds (cleanup). - """ +def remove_vless_client(email): + """Remove a client from ALL VLESS inbounds and hot-reload.""" with open(XRAY_CONFIG, 'r') as f: config = json.load(f) for inbound in config["inbounds"]: - if inbound_tag and inbound.get("tag") != inbound_tag: - continue - if inbound.get("tag") in (VLESS_NORMAL_TAG, VLESS_THROTTLED_TAG, VLESS_INBOUND_TAG): + if inbound.get("tag") in (VLESS_REALITY_TAG, VLESS_REALITY_THROTTLED_TAG): clients = inbound["settings"]["clients"] inbound["settings"]["clients"] = [ c for c in clients if c.get("email") != email @@ -621,6 +522,24 @@ def remove_vless_client(email, inbound_tag=None): print(f"[VLESS] User removed: {email[:16]}...") +def remove_vless_client_from(email, inbound_tag): + """Remove a client from one specific inbound and hot-reload.""" + with open(XRAY_CONFIG, 'r') as f: + config = json.load(f) + + for inbound in config["inbounds"]: + if inbound.get("tag") == inbound_tag: + clients = inbound["settings"]["clients"] + inbound["settings"]["clients"] = [ + c for c in clients if c.get("email") != email + ] + break + + _atomic_write_json(XRAY_CONFIG, config) + run_cmd("xray api adi --server=127.0.0.1:10085") + print(f"[VLESS] User removed from {inbound_tag}: {email[:16]}...") + + def get_xray_stats(): """Query Xray stats API for per-user upload+download bytes.