fix traffic reboot

This commit is contained in:
infosave2007
2026-01-30 19:27:02 +03:00
parent 20ca2d0df9
commit 100fb0e5a7
3 changed files with 296 additions and 75 deletions
+67 -3
View File
@@ -1246,15 +1246,29 @@ 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.");
// Update existing client entry with new UUID
$clients[$k]['id'] = $clientId;
$clients[$k]['level'] = 0; // Ensure level 0
$duplicateFound = true;
break;
}
}
// Add client
if (!$duplicateFound) {
// Add new client (no conflict)
$email = !empty($options['login']) ? $options['login'] : $clientId;
$newClient = ['id' => $clientId, 'email' => $email];
@@ -1266,8 +1280,58 @@ class InstallProtocolManager
}
}
$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);
+121 -30
View File
@@ -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 ($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)
// In our previous fetchXrayStats, we keyed by $parts[1].
$key = $client['id']; // UUID
if (!isset($this->xrayStatsCache[$key])) {
// Try name
$key = $client['name'];
}
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 (isset($this->xrayStatsCache[$key])) {
$xStats = $this->xrayStatsCache[$key];
$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
// 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 {
+94 -28
View File
@@ -301,18 +301,18 @@
<span class="text-gray-600">{{ client.expires_at|date('Y-m-d') }}</span>
{% endif %}
{% else %}
<span class="text-gray-400">{{ t('clients.never_expires') }}</span>
<span class="text-green-500 text-xl" title="{{ t('clients.never_expires') }}">&infin;</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
<div class="text-gray-600">
<td class="px-2 py-2 text-xs">
<div class="text-gray-600 font-mono">
{{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
<div class="text-gray-600">
<div class="text-gray-600 font-mono">
{{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
</div>
</td>
<td class="px-6 py-4 text-sm">
<td class="px-2 py-2 text-xs text-center">
{% 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 %}
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
<span class="px-2 py-1 bg-red-100 text-red-800 rounded">
<i class="fas fa-exclamation-circle"></i> {{ t('clients.overlimit') }}
</span>
{% elseif percentage >= 80 %}
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 rounded">
{{ used_gb }} / {{ limit_gb }} GB ({{ percentage }}%)
</span>
{% else %}
<span class="text-gray-600">{{ used_gb }} / {{ limit_gb }} GB</span>
{% endif %}
{% else %}
<span class="text-gray-400">{{ t('clients.unlimited') }}</span>
<span class="text-green-500 text-lg" title="{{ t('clients.unlimited') }}">&infin;</span>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
<div id="client-speed-{{ client.id }}" class="text-gray-600 text-xs">
<span class="text-green-600">↑ {{ (client.speed_up|default(0) / 1024)|number_format(1) }} KB/s</span><br>
<span class="text-blue-600">↓ {{ (client.speed_down|default(0) / 1024)|number_format(1) }} KB/s</span>
<td class="px-2 py-2 text-xs">
<div class="flex flex-col items-center" style="width: 120px; max-width: 120px;">
<div style="height: 30px; width: 100%;">
<canvas id="clientSparkline-{{ client.id }}"></canvas>
</div>
<div id="client-speed-{{ client.id }}" class="text-gray-600 text-[10px] mt-1 font-mono text-center leading-tight">
<div class="text-green-600 whitespace-nowrap">↑{{ ((client.speed_up|default(0) * 8) / 1000000)|number_format(2) }} Mbit</div>
<div class="text-blue-600 whitespace-nowrap">↓{{ ((client.speed_down|default(0) * 8) / 1000000)|number_format(2) }} Mbit</div>
</div>
</div>
</td>
<td class="px-6 py-4 text-sm">
<td class="px-2 py-2 text-xs whitespace-nowrap text-right">
{% if client.last_handshake %}
<span class="text-gray-600">{{ client.last_handshake }}</span>
<span class="text-gray-600 block">{{ client.last_handshake|split(' ')|first }}</span>
<span class="text-gray-400 block">{{ client.last_handshake|split(' ')|last }}</span>
{% else %}
<span class="text-gray-400">{{ t('clients.never') }}</span>
{% 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) {
const metrics = data.metrics; // Use all points for chart
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);
// 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 = `
<div class="text-green-600 whitespace-nowrap">↑${speedUp} Mbit</div>
<div class="text-blue-600 whitespace-nowrap">↓${speedDown} Mbit</div>
`;
} else {
row.innerHTML = '<span class="text-gray-400">-</span>';
}
// Format as compact badge
row.innerHTML = `<span class="text-xs text-gray-700">↑${speedUp} ↓${speedDown} KB/s</span>`;
} else {
console.log(`No metrics for client ${clientId}`);
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
}
} catch (error) {
console.error(`Failed to fetch metrics for client ${clientId}:`, error);
row.innerHTML = '<span class="text-xs text-gray-400">-</span>';
row.innerHTML = '<span class="text-xs text-gray-400">Error</span>';
}
}
}