fix traffic reboot
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
+96
-30
@@ -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') }}">∞</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
<div class="text-gray-600">
|
||||
↑ {{ (client.bytes_sent|default(0) / 1024 / 1024)|number_format(2) }} MB
|
||||
<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">
|
||||
↓ {{ (client.bytes_received|default(0) / 1024 / 1024)|number_format(2) }} MB
|
||||
<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') }}">∞</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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user