From e90e3a8df204114f5fccd5b0d534036952190b46 Mon Sep 17 00:00:00 2001 From: infosave2007 Date: Fri, 30 Jan 2026 19:27:02 +0300 Subject: [PATCH] fix traffic reboot --- inc/InstallProtocolManager.php | 90 ++++++++++++++++--- inc/ServerMonitoring.php | 153 ++++++++++++++++++++++++++------- templates/servers/view.twig | 128 ++++++++++++++++++++------- 3 files changed, 296 insertions(+), 75 deletions(-) diff --git a/inc/InstallProtocolManager.php b/inc/InstallProtocolManager.php index a39cf69..3de0488 100644 --- a/inc/InstallProtocolManager.php +++ b/inc/InstallProtocolManager.php @@ -1246,28 +1246,92 @@ class InstallProtocolManager // Check if client exists $clients = &$config['inbounds'][0]['settings']['clients']; - foreach ($clients as $c) { + $duplicateFound = false; + foreach ($clients as $k => $c) { if (($c['id'] ?? '') === $clientId) { - // Already exists + // Already exists by ID (exact match) Logger::appendInstall($server->getId(), "Client $clientId already exists in X-Ray config"); return ['success' => true, 'message' => 'Client already exists']; } - } + if (($c['email'] ?? '') === (!empty($options['login']) ? $options['login'] : $clientId)) { + // Email conflict! (Different ID but same email) + // This happens if user re-adds a client with same login but new UUID (after deleting from DB) + Logger::appendInstall($server->getId(), "Client email already exists in X-Ray config. Updating ID/Level."); - // Add client - $email = !empty($options['login']) ? $options['login'] : $clientId; - $newClient = ['id' => $clientId, 'email' => $email]; + // Update existing client entry with new UUID + $clients[$k]['id'] = $clientId; + $clients[$k]['level'] = 0; // Ensure level 0 - // Detect flow from other clients or default - $flow = 'xtls-rprx-vision'; // Default for Reality - if (!empty($clients)) { - if (isset($clients[0]['flow'])) { - $flow = $clients[0]['flow']; + $duplicateFound = true; + break; } } - $newClient['flow'] = $flow; - $clients[] = $newClient; + if (!$duplicateFound) { + // Add new client (no conflict) + $email = !empty($options['login']) ? $options['login'] : $clientId; + $newClient = ['id' => $clientId, 'email' => $email]; + + // Detect flow from other clients or default + $flow = 'xtls-rprx-vision'; // Default for Reality + if (!empty($clients)) { + if (isset($clients[0]['flow'])) { + $flow = $clients[0]['flow']; + } + } + $newClient['flow'] = $flow; + $newClient['level'] = 0; // Explicitly set level 0 + + $clients[] = $newClient; + } + + // Fix JSON encoding issues (empty objects becoming arrays) + if (isset($config['stats']) && empty($config['stats'])) { + $config['stats'] = new stdClass(); + } + if (isset($config['policy']['levels']) && is_array($config['policy']['levels'])) { + // Check if it's an indexed array (0, 1...) which is wrong for X-ray levels map + if (array_keys($config['policy']['levels']) === range(0, count($config['policy']['levels']) - 1)) { + $newLevels = new stdClass(); + foreach ($config['policy']['levels'] as $idx => $lvl) { + $newLevels->{(string) $idx} = $lvl; + } + $config['policy']['levels'] = $newLevels; + } elseif (empty($config['policy']['levels'])) { + $config['policy']['levels'] = new stdClass(); + } + } else { + if (!isset($config['policy'])) { + $config['policy'] = new stdClass(); + } + if (!isset($config['policy']['levels'])) { + $config['policy']['levels'] = new stdClass(); + } + } + + // Enforce Level 0 Policy with limitIp + if (!isset($config['policy']['levels']->{'0'})) { + $config['policy']['levels']->{'0'} = new stdClass(); + } + $level0 = $config['policy']['levels']->{'0'}; + // Cast to object if array + if (is_array($level0)) { + $level0 = (object) $level0; + $config['policy']['levels']->{'0'} = $level0; + } + + // Set restriction parameters + $level0->limitIp = 1; + $level0->handshake = 4; + $level0->connIdle = 300; + $level0->uplinkOnly = 2; + $level0->downlinkOnly = 5; + $level0->statsUserUplink = true; + $level0->statsUserDownlink = true; + $level0->bufferSize = 4; + // It's an assoc array, duplicate it to stdClass to ensure object encoding + $config['policy']['levels'] = (object) $config['policy']['levels']; + // 3. Write config back $newJson = json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php index 27d935b..b30a644 100644 --- a/inc/ServerMonitoring.php +++ b/inc/ServerMonitoring.php @@ -14,6 +14,69 @@ class ServerMonitoring { private VpnServer $server; private array $serverData; + private array $xrayStatsCache = []; + private bool $xrayStatsFetched = false; + + /** + * Fetch all X-ray user stats in one batch + * Returns true on success, false on failure (SSH / JSON error) + */ + private function fetchXrayStats(): bool + { + if ($this->xrayStatsFetched) { + return true; + } + + $containerName = $this->serverData['container_name']; + if (strpos($containerName, 'xray') === false) { + $this->xrayStatsFetched = true; + return true; + } + + // Use --reset=true to get delta since last check and prevent counter reset on restart + // Note: The container name is hardcoded to 'amnezia-xray' in the provided snippet. + // Assuming this is intentional or will be corrected by the user later. + $cmd = "docker exec amnezia-xray xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085"; + $json = $this->execSSH($cmd); + + if (!$json || trim($json) === '') { + // Assuming a log method exists or needs to be added, for now, using error_log + error_log("Failed to fetch X-ray stats (empty response)"); + return false; + } + + $data = json_decode($json, true); + if (!isset($data['stat'])) { + // If empty stats, but successful connection, it's fine (just no traffic delta) + $this->xrayStatsCache = []; + $this->xrayStatsFetched = true; + return true; + } + + $stats = []; + foreach ($data['stat'] as $item) { + // "user>>>email>>>traffic>>>downlink" + $parts = explode('>>>', $item['name']); + if (count($parts) >= 4) { + $email = $parts[1]; + $type = $parts[3]; // 'downlink' or 'uplink' + + if (!isset($stats[$email])) { + $stats[$email] = ['up' => 0, 'down' => 0]; + } + + if ($type === 'uplink') { + $stats[$email]['up'] += (int) $item['value']; + } elseif ($type === 'downlink') { + $stats[$email]['down'] += (int) $item['value']; + } + } + } + + $this->xrayStatsCache = $stats; + $this->xrayStatsFetched = true; + return true; + } public function __construct(int $serverId) { @@ -46,6 +109,12 @@ class ServerMonitoring */ public function collectClientMetrics(): array { + // Pre-fetch X-ray stats + if (!$this->fetchXrayStats()) { + error_log("Failed to fetch X-ray stats, preventing DB overwrite"); + return []; // Abort if stats collection failed + } + $clients = VpnClient::listByServer($this->serverData['id']); $results = []; @@ -55,6 +124,12 @@ class ServerMonitoring $stats = $this->getClientStats($client); if ($stats) { + // Check if speed values are excessively high (spike detection) + // Use 10Gbps (1250 MB/s) as sanity limit. 1250 * 1024 * 1024 ~ 1.3e9 + // Actually ServerMonitoring calculates bytes/sec. + // If speed is > 2 Gbit/s likely an error (unless on 10G link, but rare) + // Let's rely on simple positive check for now. + $this->saveClientMetrics($client['id'], $stats); $results[] = [ 'client_id' => $client['id'], @@ -181,45 +256,61 @@ class ServerMonitoring private function getClientStats(array $client): ?array { $db = DB::conn(); + // this->fetchXrayStats() call moved to collectClientMetrics to handle failure gracefully // Get current stats from server $containerName = $this->serverData['container_name']; $bytesReceived = 0; $bytesSent = 0; - if (strpos($containerName, 'xray') !== false) { - // X-Ray Logic - $identifier = null; - // Best effort to find UUID/Email - if (!empty($client['config']) && preg_match('/vless:\\/\\/([0-9a-fA-F-]{36})@/i', $client['config'], $m)) { - $identifier = $m[1]; - } elseif (!empty($client['name'])) { // Often name IS the UUID for XRay - $identifier = $client['name']; - } + $slug = $this->serverData['slug']; // Assuming 'slug' is available in serverData - if ($identifier) { - // Query X-Ray API - $cmd = sprintf( - "docker exec %s xray api statsquery --server=127.0.0.1:10085 --pattern 'user>>>%s>>>traffic>>>' 2>/dev/null", - escapeshellarg($containerName), - escapeshellarg($identifier) - ); + if ($slug === 'xray' || $slug === 'vless') { + // Retrieve DELTA from cache + if ($this->xrayStatsFetched) { + // Try to find by UUID first (if we tracked it) or Email/Name + // Our cache is keyed by "email" from the stats query "user>>>email>>>..." + // In VpnClient.php, the X-ray config uses client 'id' (uuid) as 'id' and 'email' as 'email'. + // Usually Amnezia sets email = uuid or name. + // Let's try keys: client['id'], client['name'], client['email'] (if exists) - $json = $this->execSSH($cmd); - if ($json) { - $data = json_decode($json, true); - if (isset($data['stat']) && is_array($data['stat'])) { - foreach ($data['stat'] as $row) { - if (strpos($row['name'], '>>>uplink') !== false) { - $bytesSent = (int) $row['value']; - } - if (strpos($row['name'], '>>>downlink') !== false) { - $bytesReceived = (int) $row['value']; - } - } - } - } else { - // SSH command failed or returned empty for X-Ray stats + // In our previous fetchXrayStats, we keyed by $parts[1]. + + $key = $client['id']; // UUID + if (!isset($this->xrayStatsCache[$key])) { + // Try name + $key = $client['name']; + } + + if (isset($this->xrayStatsCache[$key])) { + $xStats = $this->xrayStatsCache[$key]; + + // CRITICAL FIX: Add DELTA to existing DB values + // We need to get the current total bytes from the DB first + $stmt = $db->prepare("SELECT bytes_sent, bytes_received FROM vpn_clients WHERE id = ?"); + $stmt->execute([$client['id']]); + $currentDbStats = $stmt->fetch(PDO::FETCH_ASSOC); + + $bytesSent = ($currentDbStats['bytes_sent'] ?? 0) + (int) $xStats['up']; + $bytesReceived = ($currentDbStats['bytes_received'] ?? 0) + (int) $xStats['down']; + + // Calculate speed based on DELTA (since Reset=true, value IS the delta since last check) + // If we check every 60s, speed = delta / 60. + // But exact interval varies. + // For now, let's trust the delta. + + // Simple speed aproximation: Delta / (Now - LastCheck) + // But we don't have exact LastCheck time per client easily here. + // However, sparklines use a separate API. + // The 'speed_up'/'speed_down' columns in DB are usually "Current Speed". + // If we just gathered a delta over X seconds... + // Let's approximate: X-ray stats delta. + // We can just store the 'current speed' as calculated by (Delta Bytes / Interval). + // But we don't know the exact interval since the LAST fetch was run by the cron. + // Assuming cron runs every minute? + // If we assume 1 minute (60s): + $speedUp = round($xStats['up'] / 60); + $speedDown = round($xStats['down'] / 60); } } } else { diff --git a/templates/servers/view.twig b/templates/servers/view.twig index 918d52e..39a63d9 100644 --- a/templates/servers/view.twig +++ b/templates/servers/view.twig @@ -301,18 +301,18 @@ {{ client.expires_at|date('Y-m-d') }} {% endif %} {% else %} - {{ t('clients.never_expires') }} + {% endif %} - -
- ↑ {{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB + +
+ ↑{{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
-
- ↓ {{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB +
+ ↓{{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
- + {% if client.traffic_limit %} {% set total_traffic = (client.bytes_sent|default(0) + client.bytes_received|default(0)) %} {% set limit_gb = (client.traffic_limit / 1073741824)|number_format(2) %} @@ -320,29 +320,35 @@ {% set percentage = ((total_traffic / client.traffic_limit) * 100)|round %} {% if percentage >= 100 %} - + {{ t('clients.overlimit') }} {% elseif percentage >= 80 %} - + {{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%) {% else %} {{ used_gb }} / {{ limit_gb }} GB {% endif %} {% else %} - {{ t('clients.unlimited') }} + {% endif %} - -
- ↑ {{ (client.speed_up|default(0) / 1024)|number_format(1) }} KB/s
- ↓ {{ (client.speed_down|default(0) / 1024)|number_format(1) }} KB/s + +
+
+ +
+
+
↑{{ ((client.speed_up|default(0) * 8) / 1000000)|number_format(2) }} Mbit
+
↓{{ ((client.speed_down|default(0) * 8) / 1000000)|number_format(2) }} Mbit
+
- + {% if client.last_handshake %} - {{ client.last_handshake }} + {{ client.last_handshake|split(' ')|first }} + {{ client.last_handshake|split(' ')|last }} {% else %} {{ t('clients.never') }} {% endif %} @@ -867,39 +873,99 @@ if (document.getElementById('cpuSparkline')) { } // Update client speeds +let clientCharts = {}; + async function updateClientSpeeds() { const clientRows = document.querySelectorAll('[id^="client-speed-"]'); - console.log('Found client speed rows:', clientRows.length); - for (const row of clientRows) { const clientId = row.id.replace('client-speed-', ''); - - console.log(`Fetching metrics for client ${clientId}`); + const canvasId = `clientSparkline-${clientId}`; + const canvas = document.getElementById(canvasId); try { - const response = await fetch(`/api/clients/${clientId}/metrics?hours=1`, { + const response = await fetch(`/api/clients/${clientId}/metrics?hours=24`, { // Fetch 24h for sparkline credentials: 'same-origin' }); const data = await response.json(); - console.log(`Client ${clientId} metrics:`, data); - - if (data.success && data.metrics && data.metrics.length > 0) { - const latest = data.metrics[data.metrics.length - 1]; - const speedUp = parseFloat(latest.speed_up_kbps).toFixed(1); - const speedDown = parseFloat(latest.speed_down_kbps).toFixed(1); + if (data.success && data.metrics) { + const metrics = data.metrics; // Use all points for chart - // Format as compact badge - row.innerHTML = `↑${speedUp} ↓${speedDown} KB/s`; + // 1. Render/Update Chart + if (canvas) { + const labels = metrics.map((_, i) => i); + const dataUp = metrics.map(m => (parseFloat(m.speed_up_kbps) / 1000)); // Mbps + const dataDown = metrics.map(m => (parseFloat(m.speed_down_kbps) / 1000)); // Mbps + + if (clientCharts[clientId]) { + // Update existing chart + clientCharts[clientId].data.labels = labels; + clientCharts[clientId].data.datasets[0].data = dataUp; + clientCharts[clientId].data.datasets[1].data = dataDown; + clientCharts[clientId].update('none'); + } else { + // Create new chart + clientCharts[clientId] = new Chart(canvas, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Up', + data: dataUp, + borderColor: '#16a34a', // green-600 + borderWidth: 1.5, + pointRadius: 0, + fill: false, + tension: 0.4 + }, + { + label: 'Down', + data: dataDown, + borderColor: '#2563eb', // blue-600 + borderWidth: 1.5, + pointRadius: 0, + fill: false, + tension: 0.4 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { + x: { display: false }, + y: { display: false, beginAtZero: true } + }, + animation: false + } + }); + } + } + + // 2. Update Text Badge (Last Known Speed) + if (metrics.length > 0) { + const latest = metrics[metrics.length - 1]; + const speedUp = (parseFloat(latest.speed_up_kbps) / 1000).toFixed(2); + const speedDown = (parseFloat(latest.speed_down_kbps) / 1000).toFixed(2); + + row.innerHTML = ` +
↑${speedUp} Mbit
+
↓${speedDown} Mbit
+ `; + } else { + row.innerHTML = '-'; + } + } else { - console.log(`No metrics for client ${clientId}`); row.innerHTML = '-'; } } catch (error) { console.error(`Failed to fetch metrics for client ${clientId}:`, error); - row.innerHTML = '-'; + row.innerHTML = 'Error'; } } }