reality configured
This commit is contained in:
1 parent
efcfe6e706
commit
2086f9ded2
11 files changed
+205
-209
No files matched your search
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user