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:
esefxdz committed 2026-07-27 20:15:25 +03:00
1 parent 6825a264a5
commit efcfe6e706
6 files changed
+282 -63

No files matched your search

+84
View File
@@ -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,
),
),
],
),
),
);
},
);
}
}
+6 -3
View File
@@ -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';
+27
View File
@@ -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;
+35 -25
View File
@@ -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');
} }
+8
View File
@@ -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
View File
@@ -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")