Two-box throttle system + support top-up
- Server: two Xray inbounds (vless-normal / vless-throttled), 4GB threshold, management loop moves users between boxes instead of hard-killing. /session/status returns dynamic vless_path + is_throttled. Support ad callback (ad_type: support) extends session +30min +2GB. No hard quota kill — only 60-min expiry removes sessions. - App: session_timer detects vless_path change in 5s poll, triggers silent reconnect (skipCleanup, skipAdBypass, quickReconnect). New support_button.dart for top-up rewarded ads. hivemind_service uses server vless_path instead of hardcoded constant. vpn_connection: skipAdBypass, quickReconnect, skipCleanup params. - Config: Xray two-inbound template, nginx /tunnel-throttled with limit_rate 187k (1.5 Mbps). Updated AGENTS.md + SERVER_SETUP.md.
This commit is contained in:
1 parent
6825a264a5
commit
efcfe6e706
6 files changed
+282
-63
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||||
|
import 'package:revoltvpn/logic/session_timer.dart';
|
||||||
|
import 'package:revoltvpn/logic/app_colors.dart';
|
||||||
|
|
||||||
|
class SupportButton extends StatefulWidget {
|
||||||
|
const SupportButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SupportButton> createState() => _SupportButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SupportButtonState extends State<SupportButton> {
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
Future<void> _handleTap() async {
|
||||||
|
if (_busy) return;
|
||||||
|
|
||||||
|
final ad = context.read<AdManager>();
|
||||||
|
final timer = context.read<SessionTimer>();
|
||||||
|
|
||||||
|
// Only available when there's an active session.
|
||||||
|
if (!timer.isRunning && !timer.hasSyncedOnce) return;
|
||||||
|
|
||||||
|
setState(() => _busy = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ad.showAd('support');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<SessionTimer>(
|
||||||
|
builder: (context, timer, _) {
|
||||||
|
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _handleTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.glassBg,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.accent.withAlpha(100),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.favorite_border, color: AppColors.accent, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.accent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Support us \u2014 30 min!',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textWhite,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ class HivemindService {
|
|||||||
/// has usually already fired.
|
/// has usually already fired.
|
||||||
static Future<String> fetchConfigWithPolling({
|
static Future<String> fetchConfigWithPolling({
|
||||||
void Function(int attempt, int total)? onAttempt,
|
void Function(int attempt, int total)? onAttempt,
|
||||||
|
bool skipAdBypass = false,
|
||||||
|
bool quickReconnect = false,
|
||||||
}) async {
|
}) async {
|
||||||
final deviceId = await CryptoService.getDeviceId();
|
final deviceId = await CryptoService.getDeviceId();
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ class HivemindService {
|
|||||||
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||||
|
|
||||||
// ── AD BYPASS (debug only) ────────────────────────────────────────
|
// ── AD BYPASS (debug only) ────────────────────────────────────────
|
||||||
if (kDebugMode) {
|
if (kDebugMode && !skipAdBypass) {
|
||||||
try {
|
try {
|
||||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||||
final fakeUrl = Uri.parse(
|
final fakeUrl = Uri.parse(
|
||||||
@@ -44,7 +46,7 @@ class HivemindService {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxAttempts = 5;
|
final maxAttempts = quickReconnect ? 1 : 5;
|
||||||
for (int i = 1; i <= maxAttempts; i++) {
|
for (int i = 1; i <= maxAttempts; i++) {
|
||||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||||
|
|
||||||
@@ -59,8 +61,9 @@ 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}'
|
final vlessUrl = 'vless://$vlessUuid@${AppConfig.serverDomain}:${AppConfig.serverPort}'
|
||||||
'?path=${Uri.encodeComponent(AppConfig.vlessPath)}'
|
'?path=${Uri.encodeComponent(vlessPath)}'
|
||||||
'&security=${AppConfig.vlessSecurity}'
|
'&security=${AppConfig.vlessSecurity}'
|
||||||
'&type=${AppConfig.vlessType}'
|
'&type=${AppConfig.vlessType}'
|
||||||
'#ReVoltVPN';
|
'#ReVoltVPN';
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
bool _isDisconnecting = false;
|
bool _isDisconnecting = false;
|
||||||
bool _userInitiatedStop = false;
|
bool _userInitiatedStop = false;
|
||||||
|
|
||||||
|
String? _currentPath;
|
||||||
|
bool _isThrottled = false;
|
||||||
|
bool _pathSwitchInProgress = false;
|
||||||
|
|
||||||
static const int _maxConsecutiveFailures = 3;
|
static const int _maxConsecutiveFailures = 3;
|
||||||
|
|
||||||
static const int _maxOfflineSeconds = 120;
|
static const int _maxOfflineSeconds = 120;
|
||||||
@@ -236,6 +240,29 @@ class SessionTimer extends ChangeNotifier {
|
|||||||
_lastUsedBytes = _usedBytes;
|
_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;
|
||||||
|
_isThrottled = data['is_throttled'] ?? false;
|
||||||
|
_pathSwitchInProgress = true;
|
||||||
|
|
||||||
|
_timer?.cancel();
|
||||||
|
_isDisconnecting = true; // block _stopInternal during relocation
|
||||||
|
await vpnConnection.disconnect(skipCleanup: true);
|
||||||
|
await vpnConnection.connect(skipAdBypass: true, quickReconnect: true);
|
||||||
|
|
||||||
|
_resumeTicking();
|
||||||
|
_pathSwitchInProgress = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_currentPath = serverPath;
|
||||||
|
_isThrottled = data['is_throttled'] ?? false;
|
||||||
|
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
_offlineSeconds = 0;
|
||||||
_hasSyncedOnce = true;
|
_hasSyncedOnce = true;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> connect() async {
|
Future<bool> connect({bool skipAdBypass = false, bool quickReconnect = false}) async {
|
||||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -118,8 +118,12 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
String vlessUrl;
|
String vlessUrl;
|
||||||
try {
|
try {
|
||||||
vlessUrl = await HivemindService.fetchConfigWithPolling(
|
vlessUrl = await HivemindService.fetchConfigWithPolling(
|
||||||
|
skipAdBypass: skipAdBypass,
|
||||||
|
quickReconnect: quickReconnect,
|
||||||
onAttempt: (attempt, total) {
|
onAttempt: (attempt, total) {
|
||||||
_setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
if (!quickReconnect) {
|
||||||
|
_setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -156,31 +160,35 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
config: config,
|
config: config,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Give the tunnel a moment to stabilise
|
if (quickReconnect) {
|
||||||
_setStatus(VpnStatus.connecting, 'Verifying session…');
|
_setStatus(VpnStatus.connected, 'Secured');
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
} else {
|
||||||
|
// Give the tunnel a moment to stabilise
|
||||||
|
_setStatus(VpnStatus.connecting, 'Verifying session…');
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
|
||||||
bool sessionOk = false;
|
bool sessionOk = false;
|
||||||
for (int attempt = 0; attempt < 2; attempt++) {
|
for (int attempt = 0; attempt < 2; attempt++) {
|
||||||
sessionOk = await HivemindService.verifySession();
|
sessionOk = await HivemindService.verifySession();
|
||||||
if (sessionOk) break;
|
if (sessionOk) break;
|
||||||
if (attempt == 0) {
|
if (attempt == 0) {
|
||||||
debugPrint('[VPN] verifySession attempt 1 failed, retrying…');
|
debugPrint('[VPN] verifySession attempt 1 failed, retrying…');
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionOk) {
|
if (!sessionOk) {
|
||||||
debugPrint('[VPN] Tunnel started but no server session — tearing down.');
|
debugPrint('[VPN] Tunnel started but no server session — tearing down.');
|
||||||
try {
|
try {
|
||||||
await _vless.stopVless();
|
await _vless.stopVless();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
|
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
|
||||||
_setStatus(VpnStatus.error, 'Session rejected');
|
_setStatus(VpnStatus.error, 'Session rejected');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_setStatus(VpnStatus.connected, 'Secured');
|
_setStatus(VpnStatus.connected, 'Secured');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[VPN] VLESS tunnel error: $e');
|
debugPrint('[VPN] VLESS tunnel error: $e');
|
||||||
@@ -190,7 +198,7 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> disconnect() async {
|
Future<void> disconnect({bool skipCleanup = false}) async {
|
||||||
_cancelled = true;
|
_cancelled = true;
|
||||||
HivemindService.cancel(); // abort any in-progress polling
|
HivemindService.cancel(); // abort any in-progress polling
|
||||||
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
|
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
|
||||||
@@ -214,7 +222,9 @@ class VpnConnection extends ChangeNotifier {
|
|||||||
debugPrint('[VPN] VLESS stop error: $e');
|
debugPrint('[VPN] VLESS stop error: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
HivemindService.disconnectAndCleanup();
|
if (!skipCleanup) {
|
||||||
|
HivemindService.disconnectAndCleanup();
|
||||||
|
}
|
||||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:revoltvpn/components/sidebar_drawer.dart';
|
|||||||
import 'package:revoltvpn/components/timer_box.dart';
|
import 'package:revoltvpn/components/timer_box.dart';
|
||||||
import 'package:revoltvpn/components/speed_box.dart';
|
import 'package:revoltvpn/components/speed_box.dart';
|
||||||
import 'package:revoltvpn/components/status_bar.dart';
|
import 'package:revoltvpn/components/status_bar.dart';
|
||||||
|
import 'package:revoltvpn/components/support_button.dart';
|
||||||
|
|
||||||
class MainScreen extends StatelessWidget {
|
class MainScreen extends StatelessWidget {
|
||||||
const MainScreen({super.key});
|
const MainScreen({super.key});
|
||||||
@@ -47,6 +48,13 @@ class MainScreen extends StatelessWidget {
|
|||||||
child: const Center(child: TimerBox()),
|
child: const Center(child: TimerBox()),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
Positioned(
|
||||||
|
top: 175, // <-- CHANGE ME: support button from top
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: const Center(child: SupportButton()),
|
||||||
|
),
|
||||||
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 250, // <-- CHANGE ME: connect button from top
|
top: 250, // <-- CHANGE ME: connect button from top
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
+122
-35
@@ -79,37 +79,40 @@ CHECK_INTERVAL_SECONDS = 60 # Checking every 1 minute is extremely safe and
|
|||||||
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)
|
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 server info — returned to app in /session/status
|
||||||
VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain
|
VLESS_DOMAIN = "yourdomain.com" # TODO: set your real domain
|
||||||
VLESS_PATH = "/tunnel"
|
VLESS_PATH = "/tunnel"
|
||||||
|
|
||||||
# ── Xray config.json template ────────────────────────────────────────────────
|
# ── Xray config.json template ────────────────────────────────────────────────
|
||||||
# Copy this to /usr/local/etc/xray/config.json on the server.
|
# Copy this to /usr/local/etc/xray/config.json on the server.
|
||||||
# Verified against https://xtls.github.io/llms-full.txt (2026-07-24).
|
|
||||||
#
|
#
|
||||||
# • "clients" is the correct VLESS key (not "users")
|
# • Two VLESS inbounds: vless-normal (full speed) + vless-throttled (1.5 Mbps via nginx)
|
||||||
# • "method" is the transport selector (not "network")
|
# • nginx routes /tunnel → :8443 and /tunnel-throttled → :8444 with limit_rate 187k
|
||||||
# • API uses simplified mode — no separate inbound or routing rule needed
|
# • Hivemind moves users between inbounds at 4 GB threshold
|
||||||
# • statsUserUplink/Downlink must be true for per-user quota tracking
|
|
||||||
# • uplinkOnly/downlinkOnly are connection timeouts (seconds), NOT bandwidth limits
|
|
||||||
#
|
#
|
||||||
# {
|
# {
|
||||||
# "log": { "loglevel": "warning" },
|
# "log": { "loglevel": "warning" },
|
||||||
# "inbounds": [
|
# "inbounds": [
|
||||||
# {
|
# {
|
||||||
# "tag": "vless-in",
|
# "tag": "vless-normal",
|
||||||
# "port": 8443,
|
# "port": 8443,
|
||||||
# "listen": "127.0.0.1",
|
# "listen": "127.0.0.1",
|
||||||
# "protocol": "vless",
|
# "protocol": "vless",
|
||||||
# "settings": {
|
# "settings": { "clients": [], "decryption": "none" },
|
||||||
# "clients": [],
|
# "streamSettings": { "method": "ws", "wsSettings": { "path": "/tunnel" } },
|
||||||
# "decryption": "none"
|
# "sniffing": { "enabled": true, "destOverride": ["http", "tls"] }
|
||||||
# },
|
# },
|
||||||
# "streamSettings": {
|
# {
|
||||||
# "method": "ws",
|
# "tag": "vless-throttled",
|
||||||
# "security": "none",
|
# "port": 8444,
|
||||||
# "wsSettings": { "path": "/tunnel" }
|
# "listen": "127.0.0.1",
|
||||||
# },
|
# "protocol": "vless",
|
||||||
|
# "settings": { "clients": [], "decryption": "none" },
|
||||||
|
# "streamSettings": { "method": "ws", "wsSettings": { "path": "/tunnel" } },
|
||||||
# "sniffing": { "enabled": true, "destOverride": ["http", "tls"] }
|
# "sniffing": { "enabled": true, "destOverride": ["http", "tls"] }
|
||||||
# }
|
# }
|
||||||
# ],
|
# ],
|
||||||
@@ -136,11 +139,38 @@ VLESS_PATH = "/tunnel"
|
|||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
|
# "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_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"
|
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
|
||||||
|
|
||||||
|
THROTTLE_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB — throttle beyond this
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -223,8 +253,13 @@ def admob_callback():
|
|||||||
print(f"[ADMOB] Rejected — invalid device_id: {str(device_id)[:32]}...")
|
print(f"[ADMOB] Rejected — invalid device_id: {str(device_id)[:32]}...")
|
||||||
return "Invalid device_id", 400
|
return "Invalid device_id", 400
|
||||||
|
|
||||||
nonce = custom_data.get('nonce', None)
|
nonce = custom_data.get('nonce', None)
|
||||||
success, msg = _start_or_extend_session(device_id, nonce)
|
ad_type = custom_data.get('ad_type', 'main')
|
||||||
|
|
||||||
|
if ad_type == 'support':
|
||||||
|
success, msg = _extend_session(device_id, nonce)
|
||||||
|
else:
|
||||||
|
success, msg = _start_or_extend_session(device_id, nonce)
|
||||||
return ("OK", 200) if success else (msg, 500)
|
return ("OK", 200) if success else (msg, 500)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -266,6 +301,8 @@ def session_status():
|
|||||||
"used_bytes": used_bytes,
|
"used_bytes": used_bytes,
|
||||||
"remaining_bytes": remaining_data,
|
"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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -313,6 +350,7 @@ def _start_or_extend_session(device_id, nonce=None):
|
|||||||
"expires_at": now + (MAIN_AD_MINUTES * 60),
|
"expires_at": now + (MAIN_AD_MINUTES * 60),
|
||||||
"quota_bytes": MAIN_AD_BYTES,
|
"quota_bytes": MAIN_AD_BYTES,
|
||||||
"nonce": nonce,
|
"nonce": nonce,
|
||||||
|
"throttled": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
@@ -326,10 +364,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)
|
add_vless_client(arg, device_id, inbound_tag=VLESS_NORMAL_TAG)
|
||||||
return True, "Session active."
|
return True, "Session active."
|
||||||
elif op == "add":
|
elif op == "add":
|
||||||
add_vless_client(arg, device_id)
|
add_vless_client(arg, device_id, inbound_tag=VLESS_NORMAL_TAG)
|
||||||
return True, "Session active."
|
return True, "Session active."
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -339,6 +377,29 @@ 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.
|
||||||
|
|
||||||
|
Used by the 'Support us' rewarded ad. Does NOT touch the throttle state
|
||||||
|
or move the user between inbounds.
|
||||||
|
"""
|
||||||
|
with sessions_lock:
|
||||||
|
session = active_sessions.get(device_id)
|
||||||
|
if not session:
|
||||||
|
print(f"[SESSION] Extend failed — no active session for {device_id}.")
|
||||||
|
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")
|
||||||
|
|
||||||
|
return True, "Session extended"
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# ADMOB SERVER-TO-SERVER (SSV) VERIFICATION
|
# ADMOB SERVER-TO-SERVER (SSV) VERIFICATION
|
||||||
@@ -438,7 +499,9 @@ def management_loop():
|
|||||||
|
|
||||||
# ── Phase 1: decide what to do under the lock ────────────────
|
# ── Phase 1: decide what to do under the lock ────────────────
|
||||||
with sessions_lock:
|
with sessions_lock:
|
||||||
to_remove = [] # (device_id,) — expired or over quota
|
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)
|
||||||
|
|
||||||
for device_id, session in list(active_sessions.items()):
|
for device_id, session in list(active_sessions.items()):
|
||||||
|
|
||||||
@@ -448,12 +511,23 @@ def management_loop():
|
|||||||
to_remove.append(device_id)
|
to_remove.append(device_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# ── 2. Check data quota — hard cut-off when exceeded ──────
|
# ── 2. Check throttle threshold ────────────────────────────
|
||||||
used_bytes = stats.get(device_id, {}).get("total_bytes", 0)
|
used_bytes = stats.get(device_id, {}).get("total_bytes", 0)
|
||||||
|
|
||||||
if used_bytes > session["quota_bytes"]:
|
just_throttled = False
|
||||||
print(f"[HIVEMIND] Data cap reached for {device_id} ({used_bytes/1e9:.2f} GB used). Cutting off.")
|
if used_bytes > THROTTLE_BYTES and not session.get("throttled", False):
|
||||||
to_remove.append(device_id)
|
print(f"[HIVEMIND] Throttling {device_id} ({used_bytes/1e9:.2f} GB used).")
|
||||||
|
session["throttled"] = True
|
||||||
|
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)
|
||||||
|
session["_cleaned_normal"] = True
|
||||||
|
|
||||||
for device_id in to_remove:
|
for device_id in to_remove:
|
||||||
del active_sessions[device_id]
|
del active_sessions[device_id]
|
||||||
@@ -462,6 +536,14 @@ def management_loop():
|
|||||||
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:
|
||||||
|
add_vless_client(uuid, device_id, inbound_tag=VLESS_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)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[HIVEMIND] Unhandled error: {e}")
|
print(f"[HIVEMIND] Unhandled error: {e}")
|
||||||
|
|
||||||
@@ -493,17 +575,17 @@ def run_cmd(cmd, ignore_errors=False):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def add_vless_client(uuid, email, level=0):
|
def add_vless_client(uuid, email, level=0, inbound_tag=VLESS_NORMAL_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:
|
level -- Xray policy level for bandwidth limits: 0 = default.
|
||||||
0 = default.
|
inbound_tag -- which inbound to add the client to (VLESS_NORMAL_TAG or VLESS_THROTTLED_TAG).
|
||||||
"""
|
"""
|
||||||
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.get("tag") == VLESS_INBOUND_TAG:
|
if inbound.get("tag") == inbound_tag:
|
||||||
inbound["settings"]["clients"].append({
|
inbound["settings"]["clients"].append({
|
||||||
"id": uuid,
|
"id": uuid,
|
||||||
"email": email,
|
"email": email,
|
||||||
@@ -513,21 +595,26 @@ def add_vless_client(uuid, email, level=0):
|
|||||||
|
|
||||||
_atomic_write_json(XRAY_CONFIG, config)
|
_atomic_write_json(XRAY_CONFIG, config)
|
||||||
run_cmd("xray api adi --server=127.0.0.1:10085")
|
run_cmd("xray api adi --server=127.0.0.1:10085")
|
||||||
print(f"[VLESS] User added: {email[:16]}...")
|
print(f"[VLESS] User added to {inbound_tag}: {email[:16]}...")
|
||||||
|
|
||||||
|
|
||||||
def remove_vless_client(email):
|
def remove_vless_client(email, inbound_tag=None):
|
||||||
"""Remove a client from the Xray inbound config and hot-reload."""
|
"""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).
|
||||||
|
"""
|
||||||
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.get("tag") == VLESS_INBOUND_TAG:
|
if inbound_tag and inbound.get("tag") != inbound_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
|
||||||
]
|
]
|
||||||
break
|
|
||||||
|
|
||||||
_atomic_write_json(XRAY_CONFIG, config)
|
_atomic_write_json(XRAY_CONFIG, config)
|
||||||
run_cmd("xray api adi --server=127.0.0.1:10085")
|
run_cmd("xray api adi --server=127.0.0.1:10085")
|
||||||
|
|||||||
Reference in new issue
Block a user