reality configured

This commit is contained in:
esefxdz committed 2026-07-27 23:10:11 +03:00
1 parent efcfe6e706
commit 2086f9ded2
11 files changed
+205 -209

No files matched your search

+2
View File
@@ -81,3 +81,5 @@ server_full_picture_2.txt
hivemind_backup.py hivemind_backup.py
UI_POSITIONING.md UI_POSITIONING.md
SERVER_SETUP.md SERVER_SETUP.md
REALITY_MIGRATION_PLAN.md
xray_config_reality.json
+1 -1
View File
@@ -78,7 +78,7 @@ class _ConnectButtonState extends State<ConnectButton>
setState(() => _busy = true); setState(() => _busy = true);
try { try {
final adWatched = await ad.showAd('main_ad'); final adWatched = await ad.showAd('main');
if (!adWatched && AdManager.adsEnabled) { if (!adWatched && AdManager.adsEnabled) {
setState(() => _busy = false); setState(() => _busy = false);
return; return;
+4 -6
View File
@@ -14,7 +14,7 @@ class StatusBar extends StatelessWidget {
builder: (context, vpn, timer, _) { builder: (context, vpn, timer, _) {
final isConnected = vpn.status == VpnStatus.connected; final isConnected = vpn.status == VpnStatus.connected;
final isConnecting = vpn.status == VpnStatus.connecting; final isConnecting = vpn.status == VpnStatus.connecting;
final serverHealthy = isConnected && timer.hasSyncedOnce; final serverHealthy = vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 24), margin: const EdgeInsets.symmetric(horizontal: 24),
@@ -55,11 +55,9 @@ class StatusBar extends StatelessWidget {
// ── Server health dot (right) ──────────────────────────── // ── Server health dot (right) ────────────────────────────
OnlineDot( OnlineDot(
isOnline: serverHealthy, isOnline: serverHealthy,
label: isConnected && !timer.hasSyncedOnce label: isConnected
? 'Syncing…' ? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
: serverHealthy : (vpn.serverReachable ? 'Ready' : 'Offline'),
? 'Online'
: 'Offline',
), ),
], ],
), ),
+6
View File
@@ -24,6 +24,12 @@ class TimerBox extends StatelessWidget {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.textWhite, color: AppColors.textWhite,
letterSpacing: 2, letterSpacing: 2,
shadows: [
Shadow(
color: AppColors.accent50,
blurRadius: 16,
),
],
), ),
), ),
); );
+15 -8
View File
@@ -3,22 +3,29 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Rename this file to `app_config.dart` and fill in your real values. // Rename this file to `app_config.dart` and fill in your real values.
// Do not commit your real `app_config.dart` to GitHub! // 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 { abstract final class AppConfig {
AppConfig._(); AppConfig._();
// ── Server ───────────────────────────────────────────────────────────── // ── Server (API) ──────────────────────────────────────────────────────
static const String serverDomain = 'yourdomain.com'; // REPLACE static const String serverDomain = 'yourdomain.com'; // ◄── REPLACE
static const String vlessPath = '/tunnel'; // REPLACE if different
static String get hivemindApiBase => 'https://$serverDomain/api'; static String get hivemindApiBase => 'https://$serverDomain/api';
// ── VLESS ────────────────────────────────────────────────────────────── // ── Reality (VPN tunnel) ──────────────────────────────────────────────
static const int serverPort = 443; // These are fallbacks — normally overridden by /session/status response.
static const String vlessSecurity = 'tls'; static const String realitySni = 'www.microsoft.com';
static const String vlessType = 'ws'; 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 ────────────────────────────────────────────────────────────── // ── AdMob ──────────────────────────────────────────────────────────────
static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE static const String adUnitId = 'ca-app-pub-0000000000000000/0000000000'; // REPLACE
+26 -4
View File
@@ -61,11 +61,23 @@ class HivemindService {
debugPrint('[HivemindService] Nonce mismatch — retrying…'); debugPrint('[HivemindService] Nonce mismatch — retrying…');
} else if (data['active'] == true && data['vless_uuid'] != null) { } else if (data['active'] == true && data['vless_uuid'] != null) {
final vlessUuid = data['vless_uuid']; final vlessUuid = data['vless_uuid'];
final vlessPath = data['vless_path'] ?? AppConfig.vlessPath;
final vlessUrl = 'vless://$vlessUuid@${AppConfig.serverDomain}:${AppConfig.serverPort}' // Reality tunnel fields — server is source of truth, AppConfig is fallback.
'?path=${Uri.encodeComponent(vlessPath)}' final vlessIp = data['vless_ip'] ?? AppConfig.serverDomain;
'&security=${AppConfig.vlessSecurity}' 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}' '&type=${AppConfig.vlessType}'
'&flow=${AppConfig.vlessFlow}'
'&pbk=${Uri.encodeComponent(pbk)}'
'&sni=${Uri.encodeComponent(sni)}'
'&sid=${Uri.encodeComponent(sid)}'
'&fp=${Uri.encodeComponent(fp)}'
'#ReVoltVPN'; '#ReVoltVPN';
_expectedNonce = null; _expectedNonce = null;
return vlessUrl; return vlessUrl;
@@ -82,6 +94,16 @@ class HivemindService {
throw Exception('Session not activated. Server callback may have timed out.'); throw Exception('Session not activated. Server callback may have timed out.');
} }
static Future<bool> 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<bool> verifySession() async { static Future<bool> verifySession() async {
try { try {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
+17 -40
View File
@@ -14,7 +14,6 @@ class SessionTimer extends ChangeNotifier {
final VpnConnection vpnConnection; final VpnConnection vpnConnection;
int _remainingSeconds = 0; int _remainingSeconds = 0;
int _quotaBytes = 0;
int _usedBytes = 0; int _usedBytes = 0;
bool _hasSyncedOnce = false; bool _hasSyncedOnce = false;
@@ -22,9 +21,10 @@ class SessionTimer extends ChangeNotifier {
bool _isDisconnecting = false; bool _isDisconnecting = false;
bool _userInitiatedStop = false; bool _userInitiatedStop = false;
String? _currentPath;
bool _isThrottled = false; bool _isThrottled = false;
bool _pathSwitchInProgress = false;
int? _currentPort;
bool _portSwitchInProgress = false;
static const int _maxConsecutiveFailures = 3; static const int _maxConsecutiveFailures = 3;
@@ -48,9 +48,7 @@ class SessionTimer extends ChangeNotifier {
bool get isExpired => _remainingSeconds <= 0 && !isRunning; bool get isExpired => _remainingSeconds <= 0 && !isRunning;
bool get hasSyncedOnce => _hasSyncedOnce; bool get hasSyncedOnce => _hasSyncedOnce;
int get quotaBytes => _quotaBytes;
int get usedBytes => _usedBytes; int get usedBytes => _usedBytes;
int get remainingBytes => _quotaBytes > _usedBytes ? _quotaBytes - _usedBytes : 0;
double get currentSpeedKBps => _currentSpeedKBps; double get currentSpeedKBps => _currentSpeedKBps;
String get formatted { String get formatted {
@@ -60,27 +58,6 @@ class SessionTimer extends ChangeNotifier {
return '$h:$m:$s'; 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 // Lifecycle
/// Fires whenever VpnConnection state changes. /// Fires whenever VpnConnection state changes.
@@ -113,7 +90,6 @@ class SessionTimer extends ChangeNotifier {
Future<void> start(String adType) async { Future<void> start(String adType) async {
_remainingSeconds = 0; _remainingSeconds = 0;
_quotaBytes = 0;
_usedBytes = 0; _usedBytes = 0;
_lastUsedBytes = 0; _lastUsedBytes = 0;
_currentSpeedKBps = 0.0; _currentSpeedKBps = 0.0;
@@ -123,6 +99,8 @@ class SessionTimer extends ChangeNotifier {
_offlineSeconds = 0; _offlineSeconds = 0;
_isDisconnecting = false; _isDisconnecting = false;
_userInitiatedStop = false; _userInitiatedStop = false;
_currentPort = null;
_portSwitchInProgress = false;
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick); _timer = Timer.periodic(const Duration(seconds: 1), _tick);
@@ -227,7 +205,6 @@ class SessionTimer extends ChangeNotifier {
} }
_remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds; _remainingSeconds = data['expires_in_seconds'] ?? _remainingSeconds;
_quotaBytes = data['quota_bytes'] ?? _quotaBytes;
_usedBytes = data['used_bytes'] ?? _usedBytes; _usedBytes = data['used_bytes'] ?? _usedBytes;
@@ -240,27 +217,27 @@ class SessionTimer extends ChangeNotifier {
_lastUsedBytes = _usedBytes; _lastUsedBytes = _usedBytes;
// ── Detect path changes (throttle engage / disengage) ────── // ── Detect port change (throttle engage / disengage) ──────
final serverPath = data['vless_path'] as String?; final serverPort = data['vless_port'];
if (serverPath != null && if (serverPort != null &&
_currentPath != null && _currentPort != null &&
serverPath != _currentPath && serverPort != _currentPort &&
!_pathSwitchInProgress) { !_portSwitchInProgress) {
debugPrint('[Timer] Path changed $_currentPath → $serverPath — reconnecting…'); debugPrint('[Timer] Port changed $_currentPort → $serverPort — reconnecting…');
_currentPath = serverPath; _currentPort = serverPort;
_isThrottled = data['is_throttled'] ?? false; _isThrottled = data['is_throttled'] ?? false;
_pathSwitchInProgress = true; _portSwitchInProgress = true;
_timer?.cancel(); _timer?.cancel();
_isDisconnecting = true; // block _stopInternal during relocation _isDisconnecting = true;
await vpnConnection.disconnect(skipCleanup: true); await vpnConnection.disconnect(skipCleanup: true);
await vpnConnection.connect(skipAdBypass: true, quickReconnect: true); await vpnConnection.connect(skipAdBypass: true, quickReconnect: true);
_resumeTicking(); _resumeTicking();
_pathSwitchInProgress = false; _portSwitchInProgress = false;
return; return;
} }
_currentPath = serverPath; _currentPort = serverPort;
_isThrottled = data['is_throttled'] ?? false; _isThrottled = data['is_throttled'] ?? false;
_consecutiveFailures = 0; _consecutiveFailures = 0;
+17 -3
View File
@@ -26,6 +26,11 @@ class VpnConnection extends ChangeNotifier {
bool _isStartupRestoration = false; bool _isStartupRestoration = false;
bool get isStartupRestoration => _isStartupRestoration; bool get isStartupRestoration => _isStartupRestoration;
bool _serverReachable = false;
bool get serverReachable => _serverReachable;
Timer? _healthTimer;
late final FlutterVless _vless; late final FlutterVless _vless;
bool _initialized = false; bool _initialized = false;
@@ -53,6 +58,10 @@ class VpnConnection extends ChangeNotifier {
debugPrint('[VPN] VLESS init error (expected on emulator): $e'); 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) // Check if VPN was already running (app killed and reopened)
try { try {
final coreVersion = await _vless.getCoreVersion(); final coreVersion = await _vless.getCoreVersion();
@@ -62,9 +71,6 @@ class VpnConnection extends ChangeNotifier {
if (delay > 0) { if (delay > 0) {
_isStartupRestoration = true; _isStartupRestoration = true;
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, 'Secured');
Future.delayed(const Duration(seconds: 5), () {
_isStartupRestoration = false;
});
} }
} catch (_) {} } catch (_) {}
} }
@@ -75,12 +81,14 @@ class VpnConnection extends ChangeNotifier {
_setStatus(VpnStatus.connected, 'Secured'); _setStatus(VpnStatus.connected, 'Secured');
break; break;
case VlessConnectionState.disconnected: case VlessConnectionState.disconnected:
_isStartupRestoration = false;
_setStatus(VpnStatus.disconnected, 'Tap to connect'); _setStatus(VpnStatus.disconnected, 'Tap to connect');
break; break;
case VlessConnectionState.connecting: case VlessConnectionState.connecting:
_setStatus(VpnStatus.connecting, 'Establishing tunnel…'); _setStatus(VpnStatus.connecting, 'Establishing tunnel…');
break; break;
case VlessConnectionState.disconnecting: case VlessConnectionState.disconnecting:
_isStartupRestoration = false;
_setStatus(VpnStatus.disconnecting, 'Tearing down…'); _setStatus(VpnStatus.disconnecting, 'Tearing down…');
break; break;
case VlessConnectionState.unknown: case VlessConnectionState.unknown:
@@ -234,8 +242,14 @@ class VpnConnection extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> _checkHealth() async {
_serverReachable = await HivemindService.checkHealth();
notifyListeners();
}
@override @override
void dispose() { void dispose() {
_healthTimer?.cancel();
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) { if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
try { try {
_vless.stopVless(); _vless.stopVless();
+10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:ui';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart';
@@ -22,6 +23,15 @@ void main() async {
systemNavigationBarIconBrightness: Brightness.light, 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 ConsentManager.requestConsentIfNeeded();
await MobileAds.instance.initialize(); await MobileAds.instance.initialize();
+41
View File
@@ -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;
}
+66 -147
View File
@@ -1,19 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Hivemind VPN Session Manager 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. Designed for Android-only clients.
=== TABLE OF CONTENTS =========================================================== === TABLE OF CONTENTS ===========================================================
LINE SECTION Configuration — ad rewards, Reality constants, Xray paths
37 Configuration — ad rewards, Xray paths State & Cache — sessions dict, stats failure counter
75 State & Cache — sessions dict, stats failure counter Flask Routes — /admob/callback, /session/status, /session/stop
97 Flask Routes — /admob/callback, /session/status, /session/stop Session Logic — _start_or_extend_session, _extend_session
202 Session Logic — _start_or_extend_session (create / replace) AdMob SSV Verification — Google ECDSA signature checks
270 AdMob SSV Verification — Google ECDSA signature checks Management Loop — expiry + throttle watchdog (60 s tick)
355 Management Loop — expiry + data-quota watchdog (60 s tick) Low-Level Plumbing — add/remove users, Xray stats
403 Low-Level Plumbing — add/remove users, Xray stats Entry Point — daemon thread + gunicorn bootstrap
551 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 Rewards
MAIN_AD_MINUTES = 60 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 Rewards (top-up)
SUPPORT_AD_MINUTES = 30 SUPPORT_AD_MINUTES = 30
SUPPORT_AD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
# VLESS server info — returned to app in /session/status # ── Reality — returned to app in /session/status ────────────────────────
VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain REALITY_SNI = "www.microsoft.com"
VLESS_PATH = "/tunnel" 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 ──────────────────────────────────────────────── # ── Xray paths ───────────────────────────────────────────────────────────
# 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_CONFIG = "/usr/local/etc/xray/config.json" XRAY_CONFIG = "/usr/local/etc/xray/config.json"
XRAY_API = "http://127.0.0.1:10085" XRAY_API = "http://127.0.0.1:10085"
VLESS_INBOUND_TAG = "vless-in" # legacy — kept for backwards compat VLESS_REALITY_TAG = "vless-reality"
VLESS_NORMAL_TAG = "vless-normal" # full-speed inbox VLESS_REALITY_THROTTLED_TAG = "vless-reality-throttled"
VLESS_THROTTLED_TAG = "vless-throttled" # 1.5 Mbps inbox
THROTTLE_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB — throttle beyond this THROTTLE_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB
# AdMob SSV verification toggle # AdMob SSV verification toggle
# True = dev mode, accept any callback (no signature check) # 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. # Used to rate-limit log spam when the stats endpoint is unreachable.
_xray_stats_failures = 0 _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 = {} active_sessions = {}
@@ -267,6 +182,11 @@ def admob_callback():
return "Internal Error", 500 return "Internal Error", 500
@app.route('/api/health')
def health():
return jsonify({"ok": True})
@app.route('/api/session/status', methods=['GET']) @app.route('/api/session/status', methods=['GET'])
def session_status(): def session_status():
""" """
@@ -287,21 +207,22 @@ def session_status():
stats = get_xray_stats() stats = get_xray_stats()
used_bytes = stats.get(device_id, {}).get("total_bytes", 0) 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()) remaining_secs = max(0, session["expires_at"] - time.time())
return jsonify({ return jsonify({
"active": remaining_secs > 0, "active": remaining_secs > 0,
"vless_uuid": session["vless_uuid"], "vless_uuid": session["vless_uuid"],
"server_domain": VLESS_DOMAIN, "vless_ip": VLESS_SERVER_IP,
"server_port": 443, # NOTE: assumes throttled port = normal port + 1. If you change the
"vless_path": VLESS_PATH, # 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), "expires_in_seconds": int(remaining_secs),
"quota_bytes": session["quota_bytes"],
"used_bytes": used_bytes, "used_bytes": used_bytes,
"remaining_bytes": remaining_data,
"nonce": session.get("nonce", ""), "nonce": session.get("nonce", ""),
"vless_path": "/tunnel-throttled" if session.get("throttled") else VLESS_PATH,
"is_throttled": session.get("throttled", False), "is_throttled": session.get("throttled", False),
}) })
@@ -348,7 +269,6 @@ def _start_or_extend_session(device_id, nonce=None):
active_sessions[device_id] = { active_sessions[device_id] = {
"vless_uuid": vless_uuid, "vless_uuid": vless_uuid,
"expires_at": now + (MAIN_AD_MINUTES * 60), "expires_at": now + (MAIN_AD_MINUTES * 60),
"quota_bytes": MAIN_AD_BYTES,
"nonce": nonce, "nonce": nonce,
"throttled": False, "throttled": False,
} }
@@ -364,10 +284,10 @@ def _start_or_extend_session(device_id, nonce=None):
op, arg = xray_action[0], xray_action[1] op, arg = xray_action[0], xray_action[1]
if op == "replace": if op == "replace":
remove_vless_client(device_id) 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." return True, "Session active."
elif op == "add": 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." return True, "Session active."
else: else:
raise RuntimeError( raise RuntimeError(
@@ -378,7 +298,7 @@ def _start_or_extend_session(device_id, nonce=None):
def _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 Used by the 'Support us' rewarded ad. Does NOT touch the throttle state
or move the user between inbounds. or move the user between inbounds.
@@ -390,13 +310,9 @@ def _extend_session(device_id, nonce=None):
return False, "No active session to extend" return False, "No active session to extend"
session["expires_at"] += SUPPORT_AD_MINUTES * 60 session["expires_at"] += SUPPORT_AD_MINUTES * 60
session["quota_bytes"] += SUPPORT_AD_BYTES
session["nonce"] = nonce session["nonce"] = nonce
print(f"[SESSION] Extended for {device_id}: " print(f"[SESSION] Extended for {device_id}: +{SUPPORT_AD_MINUTES} min")
f"+{SUPPORT_AD_MINUTES} min, "
f"+{SUPPORT_AD_BYTES/1e9:.1f} GB, "
f"new quota={session['quota_bytes']/1e9:.1f} GB")
return True, "Session extended" 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(): def management_loop():
print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.") print(f"[HIVEMIND] Started. Tick every {CHECK_INTERVAL_SECONDS}s.")
@@ -497,21 +413,18 @@ def management_loop():
stats = get_xray_stats() stats = get_xray_stats()
now = time.time() now = time.time()
# ── Phase 1: decide what to do under the lock ────────────────
with sessions_lock: with sessions_lock:
to_remove = [] # (device_id,) — expired to_remove = []
to_throttle_add = [] # (device_id, uuid) — add to throttled box to_throttle_add = []
to_throttle_cleanup = [] # (device_id,) — remove from normal box (delayed) to_throttle_cleanup = []
for device_id, session in list(active_sessions.items()): for device_id, session in list(active_sessions.items()):
# ── 1. Check expiry ───────────────────────────────────────
if now > session["expires_at"]: if now > session["expires_at"]:
print(f"[HIVEMIND] Session expired for {device_id}.") print(f"[HIVEMIND] Session expired for {device_id}.")
to_remove.append(device_id) to_remove.append(device_id)
continue continue
# ── 2. Check throttle threshold ────────────────────────────
used_bytes = stats.get(device_id, {}).get("total_bytes", 0) used_bytes = stats.get(device_id, {}).get("total_bytes", 0)
just_throttled = False just_throttled = False
@@ -521,9 +434,6 @@ def management_loop():
just_throttled = True just_throttled = True
to_throttle_add.append((device_id, session["vless_uuid"])) 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 if (session.get("throttled") and not just_throttled
and not session.get("_cleaned_normal")): and not session.get("_cleaned_normal")):
to_throttle_cleanup.append(device_id) to_throttle_cleanup.append(device_id)
@@ -532,17 +442,14 @@ def management_loop():
for device_id in to_remove: for device_id in to_remove:
del active_sessions[device_id] del active_sessions[device_id]
# ── Phase 2: file I/O + subprocess OUTSIDE the lock ───────
for device_id in to_remove: for device_id in to_remove:
remove_vless_client(device_id) 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: 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: 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: except Exception as e:
print(f"[HIVEMIND] Unhandled error: {e}") print(f"[HIVEMIND] Unhandled error: {e}")
@@ -575,11 +482,11 @@ def run_cmd(cmd, ignore_errors=False):
return None 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. """Add a client UUID to the Xray inbound config and hot-reload.
level -- Xray policy level for bandwidth limits: 0 = default. 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: with open(XRAY_CONFIG, 'r') as f:
config = json.load(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]}...") print(f"[VLESS] User added to {inbound_tag}: {email[:16]}...")
def remove_vless_client(email, inbound_tag=None): def remove_vless_client(email):
"""Remove a client from the Xray inbound config and hot-reload. """Remove a client from ALL VLESS inbounds and hot-reload."""
inbound_tag -- if provided, only remove from that inbound.
if None, remove from ALL VLESS inbounds (cleanup).
"""
with open(XRAY_CONFIG, 'r') as f: with open(XRAY_CONFIG, 'r') as f:
config = json.load(f) config = json.load(f)
for inbound in config["inbounds"]: for inbound in config["inbounds"]:
if inbound_tag and inbound.get("tag") != inbound_tag: if inbound.get("tag") in (VLESS_REALITY_TAG, VLESS_REALITY_THROTTLED_TAG):
continue
if inbound.get("tag") in (VLESS_NORMAL_TAG, VLESS_THROTTLED_TAG, VLESS_INBOUND_TAG):
clients = inbound["settings"]["clients"] clients = inbound["settings"]["clients"]
inbound["settings"]["clients"] = [ inbound["settings"]["clients"] = [
c for c in clients if c.get("email") != email 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]}...") 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(): def get_xray_stats():
"""Query Xray stats API for per-user upload+download bytes. """Query Xray stats API for per-user upload+download bytes.