feat: Enhance online client tracking by including recent handshake counts for WireGuard/AWG

This commit is contained in:
infosave2007
2026-02-05 19:34:02 +03:00
parent 853f57bc40
commit f0a24d2e22
5 changed files with 208 additions and 105 deletions
+1
View File
@@ -67,3 +67,4 @@ test_protocols.php
scripts/regen_qr.php
scripts/test_xray_install.sh
scripts/test_online.php
API_AWG_DOCS.md
+140 -89
View File
@@ -27,19 +27,11 @@ class ServerMonitoring
return true;
}
$containerName = $this->serverData['container_name'];
if (strpos($containerName, 'xray') === false) {
$this->xrayStatsFetched = true;
return true;
}
// Always try to fetch from amnezia-xray container
// Even if server's container_name is different, there may be xray clients
$xrayContainer = $this->getXrayContainerName() ?? 'amnezia-xray';
// Use --reset=true to get delta since last check and prevent counter reset on restart
$xrayContainer = $this->getXrayContainerName();
if (!$xrayContainer) {
$this->xrayStatsFetched = true;
return true; // Not an Xray server
}
$cmd = "docker exec $xrayContainer xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085";
$cmd = "docker exec $xrayContainer xray api statsquery --pattern 'user>>>' --reset=true --server=127.0.0.1:10085 2>/dev/null";
$json = $this->execSSH($cmd);
if (!$json || trim($json) === '') {
@@ -274,24 +266,40 @@ class ServerMonitoring
$containerName = $this->serverData['container_name'];
$bytesReceived = 0;
$bytesSent = 0;
$speedUp = 0;
$speedDown = 0;
$protocol = $this->serverData['install_protocol'] ?? '';
// Determine if this client is XRay based on protocol_id
$isXrayClient = false;
if (!empty($client['protocol_id'])) {
$stmtProto = $db->prepare('SELECT slug FROM protocols WHERE id = ?');
$stmtProto->execute([$client['protocol_id']]);
$protoData = $stmtProto->fetch();
if ($protoData && stripos($protoData['slug'], 'xray') !== false) {
$isXrayClient = true;
}
}
if (strpos($protocol, 'xray') !== false || strpos($protocol, 'vless') !== false) {
// Fallback: check config for vless URI
if (!$isXrayClient && !empty($client['config']) && strpos($client['config'], 'vless://') !== false) {
$isXrayClient = true;
}
if ($isXrayClient) {
// 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
// Try name first (matches email in xray config), then UUID from config
$key = $client['name'];
if (!isset($this->xrayStatsCache[$key])) {
// Try name
$key = $client['name'];
// Try UUID from config
if (!empty($client['config']) && preg_match('/vless:\/\/([0-9a-fA-F-]{36})@/i', $client['config'], $m)) {
$key = $m[1];
}
}
if (!isset($this->xrayStatsCache[$key])) {
// Try client['id'] as last resort
$key = $client['id'];
}
if (isset($this->xrayStatsCache[$key])) {
@@ -307,32 +315,42 @@ class ServerMonitoring
$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):
// Assuming cron runs every minute (60s):
$speedUp = round($xStats['up'] / 60);
$speedDown = round($xStats['down'] / 60);
} else {
// No stats in cache, use current DB values
$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;
$bytesReceived = $currentDbStats['bytes_received'] ?? 0;
}
}
} else {
// WireGuard Logic
// WireGuard Logic - get bytes and handshake timestamp
$publicKey = $client['public_key'];
$cmd = "docker exec {$containerName} wg show all dump | grep '{$publicKey}' | awk '{print \$6, \$7}'";
// wg show all dump format (tab-separated):
// $1=interface $2=pubkey $3=psk $4=endpoint $5=allowed-ips $6=latest-handshake $7=rx-bytes $8=tx-bytes $9=keepalive
// rx-bytes = bytes received by server = client's upload (bytes_sent)
// tx-bytes = bytes transmitted by server = client's download (bytes_received)
$cmd = "docker exec {$containerName} wg show all dump | grep '{$publicKey}' | awk '{print \$6, \$7, \$8}'";
$result = $this->execSSH($cmd);
if ($result) {
list($bytesReceived, $bytesSent) = explode(' ', trim($result));
$parts = explode(' ', trim($result));
if (count($parts) >= 3) {
$handshakeTs = (int)$parts[0];
$bytesSent = (int)$parts[1]; // server's rx = client's upload
$bytesReceived = (int)$parts[2]; // server's tx = client's download
// Update last_handshake if there was a recent handshake
if ($handshakeTs > 0) {
$handshakeDate = date('Y-m-d H:i:s', $handshakeTs);
$stmtHs = $db->prepare("UPDATE vpn_clients SET last_handshake = ? WHERE id = ?");
$stmtHs->execute([$handshakeDate, $client['id']]);
}
}
}
}
@@ -425,10 +443,10 @@ class ServerMonitoring
$stats['speed_down_kbps'],
]);
// Update vpn_clients table with latest stats
// Update vpn_clients table with latest stats (don't touch last_handshake - it's set separately for WG/AWG)
$stmt = $db->prepare("
UPDATE vpn_clients
SET bytes_sent = ?, bytes_received = ?, speed_up = ?, speed_down = ?, current_speed = ?, last_handshake = NOW(), last_sync_at = NOW()
SET bytes_sent = ?, bytes_received = ?, speed_up = ?, speed_down = ?, current_speed = ?, last_sync_at = NOW()
WHERE id = ?
");
@@ -785,11 +803,23 @@ class ServerMonitoring
// Get all active servers
$servers = VpnServer::listAll();
$db = DB::conn();
foreach ($servers as $serverData) {
// Check if this is an Xray server
// Check if this server has any XRay clients
$stmt = $db->prepare("
SELECT COUNT(*) as cnt FROM vpn_clients vc
JOIN protocols p ON vc.protocol_id = p.id
WHERE vc.server_id = ? AND p.slug LIKE '%xray%'
");
$stmt->execute([$serverData['id']]);
$hasXrayClients = (int)$stmt->fetchColumn() > 0;
// Also check container_name as fallback
$containerName = $serverData['container_name'] ?? '';
if (strpos($containerName, 'xray') === false) {
$isXrayServer = strpos($containerName, 'xray') !== false;
if (!$hasXrayClients && !$isXrayServer) {
continue;
}
@@ -799,7 +829,7 @@ class ServerMonitoring
$username = $serverData['username'] ?? 'root';
$password = $serverData['password'] ?? '';
$xrayContainer = $containerName ?: 'amnezia-xray';
$xrayContainer = $isXrayServer ? $containerName : 'amnezia-xray';
$cmd = "docker exec $xrayContainer xray api statsgetallonlineusers --server=127.0.0.1:10085";
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5';
@@ -861,58 +891,79 @@ class ServerMonitoring
public static function getOnlineClientsForServer(array $serverData): array
{
$result = [];
$db = DB::conn();
// 1. Get XRay online clients from Xray API
$stmt = $db->prepare("
SELECT COUNT(*) as cnt FROM vpn_clients vc
JOIN protocols p ON vc.protocol_id = p.id
WHERE vc.server_id = ? AND p.slug LIKE '%xray%'
");
$stmt->execute([$serverData['id']]);
$hasXrayClients = (int)$stmt->fetchColumn() > 0;
// Check if this is an Xray server
$containerName = $serverData['container_name'] ?? '';
if (strpos($containerName, 'xray') === false) {
return $result;
}
$isXrayServer = strpos($containerName, 'xray') !== false;
// Build SSH command
$host = $serverData['host'];
$port = (int)($serverData['port'] ?? 22);
$username = $serverData['username'] ?? 'root';
$password = $serverData['password'] ?? '';
if ($hasXrayClients || $isXrayServer) {
$host = $serverData['host'];
$port = (int)($serverData['port'] ?? 22);
$username = $serverData['username'] ?? 'root';
$password = $serverData['password'] ?? '';
$xrayContainer = $containerName ?: 'amnezia-xray';
$cmd = "docker exec $xrayContainer xray api statsgetallonlineusers --server=127.0.0.1:10085";
$xrayContainer = $isXrayServer ? $containerName : 'amnezia-xray';
$cmd = "docker exec $xrayContainer xray api statsgetallonlineusers --server=127.0.0.1:10085";
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5';
$sshCmd = sprintf(
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null",
$password,
$port,
$sshOptions,
$username,
$host,
escapeshellarg($cmd)
);
$sshOptions = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5';
$sshCmd = sprintf(
"sshpass -p '%s' ssh -p %d %s %s@%s %s 2>/dev/null",
$password,
$port,
$sshOptions,
$username,
$host,
escapeshellarg($cmd)
);
$output = shell_exec($sshCmd);
if (!$output) {
return $result;
}
$data = json_decode($output, true);
if (!isset($data['users']) || !is_array($data['users'])) {
return $result;
}
foreach ($data['users'] as $user) {
// Parse format: "user>>>email>>>online"
if (is_string($user)) {
$parts = explode('>>>', $user);
if (count($parts) >= 2) {
$result[] = $parts[1];
}
} else {
$email = $user['email'] ?? null;
if ($email) {
$result[] = $email;
$output = shell_exec($sshCmd);
if ($output) {
$data = json_decode($output, true);
if (isset($data['users']) && is_array($data['users'])) {
foreach ($data['users'] as $user) {
if (is_string($user)) {
$parts = explode('>>>', $user);
if (count($parts) >= 2) {
$result[] = $parts[1];
}
} else {
$email = $user['email'] ?? null;
if ($email) {
$result[] = $email;
}
}
}
}
}
}
// 2. Add WireGuard/AWG clients with recent handshake (< 5 minutes)
// Exclude XRay clients - they use Xray API for online status
$stmt = $db->prepare("
SELECT vc.name FROM vpn_clients vc
LEFT JOIN protocols p ON vc.protocol_id = p.id
WHERE vc.server_id = ?
AND vc.status = 'active'
AND vc.last_handshake IS NOT NULL
AND vc.last_handshake >= DATE_SUB(NOW(), INTERVAL 300 SECOND)
AND (p.slug IS NULL OR p.slug NOT LIKE '%xray%')
");
$stmt->execute([$serverData['id']]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (!in_array($row['name'], $result)) {
$result[] = $row['name'];
}
}
return $result;
}
}
+47 -10
View File
@@ -1551,21 +1551,58 @@ class VpnClient
// XRay stats logic
$stats = [];
// Heuristic: if container name contains 'xray' or protocol slug suggests xray
$containerName = $serverData['container_name'] ?? '';
// Or better: try to detect protocol from config if container name is vague (but usually amnezia-xray)
// Determine protocol by client's protocol_id
$isXray = false;
$xrayContainerName = 'amnezia-xray'; // Default XRay container name
if (strpos($containerName, 'xray') !== false) {
// Extract UUID from config for XRay (vless://UUID@...)
if (!empty($this->data['protocol_id'])) {
$stmtProto = $pdo->prepare('SELECT slug FROM protocols WHERE id = ?');
$stmtProto->execute([$this->data['protocol_id']]);
$protoData = $stmtProto->fetch();
if ($protoData && stripos($protoData['slug'], 'xray') !== false) {
$isXray = true;
}
}
// Fallback: check container_name or config for xray indicators
if (!$isXray) {
$containerName = $serverData['container_name'] ?? '';
if (strpos($containerName, 'xray') !== false) {
$isXray = true;
$xrayContainerName = $containerName;
} elseif (!empty($this->data['config']) && strpos($this->data['config'], 'vless://') !== false) {
$isXray = true;
}
}
if ($isXray) {
// XRay stats are tracked by email field in xray config
// Try client name first (typically used as email), then UUID from config as fallback
$identifier = null;
$uuid = null;
// Extract UUID from config
if (!empty($this->data['config']) && preg_match('/vless:\/\/([0-9a-fA-F-]{36})@/i', $this->data['config'], $m)) {
$identifier = $m[1];
} elseif (!empty($this->data['name'])) {
$identifier = $this->data['name'];
$uuid = $m[1];
}
if ($identifier) {
$stats = self::getXrayStats($serverData, $identifier);
// Override container_name for XRay stats
$xrayServerData = $serverData;
$xrayServerData['container_name'] = $xrayContainerName;
// Try name first (typically matches email in xray config)
if (!empty($this->data['name'])) {
$identifier = $this->data['name'];
$stats = self::getXrayStats($xrayServerData, $identifier);
}
// If no stats found by name, try UUID
if ((empty($stats) || ($stats['bytes_sent'] == 0 && $stats['bytes_received'] == 0)) && $uuid) {
$identifier = $uuid;
$stats = self::getXrayStats($xrayServerData, $identifier);
}
if ($identifier && !empty($stats)) {
// Infer online status for XRay: if traffic increased, they are online.
// Update last_handshake to NOW() if activity detected.
if ($stats['bytes_sent'] > $prevSent || $stats['bytes_received'] > $prevReceived) {
+14 -1
View File
@@ -268,10 +268,23 @@ Router::get('/dashboard', function () {
// Get real-time online clients count from Xray API
$onlineData = ServerMonitoring::countOnlineClients();
// Also count clients with recent handshake (within 5 minutes) for WireGuard/AWG
$pdo = DB::conn();
$stmt = $pdo->query("
SELECT COUNT(*) as cnt FROM vpn_clients
WHERE last_handshake IS NOT NULL
AND last_handshake > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
AND status = 'active'
");
$recentHandshakeCount = (int)$stmt->fetchColumn();
// Combine both counts (XRay online + recent handshake), avoiding duplicates
$totalOnline = max($onlineData['total'], $recentHandshakeCount);
View::render('dashboard.twig', [
'servers' => $servers,
'clients' => $clients,
'online_count' => $onlineData['total'],
'online_count' => $totalOnline,
'online_users' => $onlineData['users'],
]);
});
+3 -2
View File
@@ -276,8 +276,9 @@
{% endif %}
</td>
<td class="px-6 py-4">{{ client.client_ip }}</td>
<td class="px-6 py-4" data-client-name="{{ client.name }}" data-client-status="{{ client.status }}">
{% if client.name in online_logins %}
<td class="px-6 py-4" data-client-name="{{ client.name }}" data-client-status="{{ client.status }}" data-last-handshake="{{ client.last_handshake }}">
{% set is_online_by_handshake = client.last_handshake and (("now"|date('U') - client.last_handshake|date('U')) < 300) %}
{% if client.name in online_logins or is_online_by_handshake %}
<span class="online-badge px-2 py-1 bg-green-100 text-green-800 rounded text-xs"><i class="fas fa-wifi mr-1"></i>Online</span>
{% elseif client.status == 'active' %}
<span class="status-badge px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{{ t('status.active') }}</span>