diff --git a/bin/collect_metrics.php b/bin/collect_metrics.php new file mode 100644 index 0000000..0f37455 --- /dev/null +++ b/bin/collect_metrics.php @@ -0,0 +1,85 @@ +#!/usr/bin/env php +collectMetrics(); + echo " Server: CPU={$serverMetrics['cpu_percent']}% RAM={$serverMetrics['ram_used_mb']}/{$serverMetrics['ram_total_mb']}MB "; + echo "Disk={$serverMetrics['disk_used_gb']}/{$serverMetrics['disk_total_gb']}GB "; + echo "Net RX={$serverMetrics['network_rx_mbps']}Mbps TX={$serverMetrics['network_tx_mbps']}Mbps\n"; + + // Collect client metrics + $clientMetrics = $monitoring->collectClientMetrics(); + + if (!empty($clientMetrics)) { + foreach ($clientMetrics as $cm) { + echo " Client #{$cm['client_id']} ({$cm['client_name']}): UP={$cm['speed_up_kbps']}Kbps DOWN={$cm['speed_down_kbps']}Kbps\n"; + } + } else { + echo " No active clients\n"; + } + + } catch (Exception $e) { + echo " ERROR: " . $e->getMessage() . "\n"; + } + } + + // Clean old metrics + ServerMonitoring::cleanOldMetrics(); + + // Calculate sleep time + $executionTime = microtime(true) - $startTime; + $sleepTime = max(0, 30 - $executionTime); + + echo "[" . date('Y-m-d H:i:s') . "] Collection completed in " . round($executionTime, 2) . "s, sleeping for " . round($sleepTime, 2) . "s\n\n"; + + if ($sleepTime > 0) { + sleep((int)$sleepTime); + } + + } catch (Exception $e) { + echo "[" . date('Y-m-d H:i:s') . "] FATAL ERROR: " . $e->getMessage() . "\n"; + echo "Retrying in 30 seconds...\n\n"; + sleep(30); + } +} diff --git a/inc/ServerMonitoring.php b/inc/ServerMonitoring.php new file mode 100644 index 0000000..af05a74 --- /dev/null +++ b/inc/ServerMonitoring.php @@ -0,0 +1,349 @@ +server = new VpnServer($serverId); + $this->serverData = $this->server->getData(); + } + + /** + * Collect all server metrics + */ + public function collectMetrics(): array + { + $metrics = [ + 'cpu_percent' => $this->getCpuUsage(), + 'ram_used_mb' => $this->getRamUsed(), + 'ram_total_mb' => $this->getRamTotal(), + 'disk_used_gb' => $this->getDiskUsed(), + 'disk_total_gb' => $this->getDiskTotal(), + 'network_rx_mbps' => $this->getNetworkRxSpeed(), + 'network_tx_mbps' => $this->getNetworkTxSpeed(), + ]; + + $this->saveServerMetrics($metrics); + + return $metrics; + } + + /** + * Collect client traffic metrics + */ + public function collectClientMetrics(): array + { + $clients = VpnClient::listByServer($this->serverData['id']); + $results = []; + + foreach ($clients as $client) { + if ($client['status'] !== 'active') continue; + + $stats = $this->getClientStats($client); + if ($stats) { + $this->saveClientMetrics($client['id'], $stats); + $results[] = [ + 'client_id' => $client['id'], + 'client_name' => $client['name'], + 'speed_up_kbps' => $stats['speed_up_kbps'], + 'speed_down_kbps' => $stats['speed_down_kbps'], + ]; + } + } + + return $results; + } + + /** + * Get CPU usage percentage + */ + private function getCpuUsage(): ?float + { + $cmd = "top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - \$1}'"; + $result = $this->execSSH($cmd); + + return $result ? (float)trim($result) : null; + } + + /** + * Get RAM used in MB + */ + private function getRamUsed(): ?int + { + $cmd = "free -m | grep Mem | awk '{print \$3}'"; + $result = $this->execSSH($cmd); + + return $result ? (int)trim($result) : null; + } + + /** + * Get total RAM in MB + */ + private function getRamTotal(): ?int + { + $cmd = "free -m | grep Mem | awk '{print \$2}'"; + $result = $this->execSSH($cmd); + + return $result ? (int)trim($result) : null; + } + + /** + * Get disk used in GB + */ + private function getDiskUsed(): ?float + { + $cmd = "df -BG / | tail -1 | awk '{print \$3}' | sed 's/G//'"; + $result = $this->execSSH($cmd); + + return $result ? (float)trim($result) : null; + } + + /** + * Get total disk in GB + */ + private function getDiskTotal(): ?float + { + $cmd = "df -BG / | tail -1 | awk '{print \$2}' | sed 's/G//'"; + $result = $this->execSSH($cmd); + + return $result ? (float)trim($result) : null; + } + + /** + * Get network RX speed in Mbps + */ + private function getNetworkRxSpeed(): ?float + { + // Get bytes received on main interface + $cmd = "cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/rx_bytes"; + $bytes1 = $this->execSSH($cmd); + + if (!$bytes1) return null; + + sleep(1); // Wait 1 second + + $bytes2 = $this->execSSH($cmd); + + if (!$bytes2) return null; + + // Calculate speed in Mbps + $bytesPerSec = (int)$bytes2 - (int)$bytes1; + $mbps = ($bytesPerSec * 8) / 1000000; + + return round($mbps, 2); + } + + /** + * Get network TX speed in Mbps + */ + private function getNetworkTxSpeed(): ?float + { + // Get bytes transmitted on main interface + $cmd = "cat /sys/class/net/\$(ip route | grep default | awk '{print \$5}' | head -1)/statistics/tx_bytes"; + $bytes1 = $this->execSSH($cmd); + + if (!$bytes1) return null; + + sleep(1); // Wait 1 second + + $bytes2 = $this->execSSH($cmd); + + if (!$bytes2) return null; + + // Calculate speed in Mbps + $bytesPerSec = (int)$bytes2 - (int)$bytes1; + $mbps = ($bytesPerSec * 8) / 1000000; + + return round($mbps, 2); + } + + /** + * Get client current stats and calculate speed + */ + private function getClientStats(array $client): ?array + { + $db = DB::conn(); + + // Get current stats from server + $containerName = $this->serverData['container_name']; + $publicKey = $client['public_key']; + + $cmd = "docker exec {$containerName} wg show all dump | grep '{$publicKey}' | awk '{print \$6, \$7}'"; + $result = $this->execSSH($cmd); + + if (!$result) return null; + + list($bytesReceived, $bytesSent) = explode(' ', trim($result)); + + // Get previous metrics (30 seconds ago) + $stmt = $db->prepare(" + SELECT bytes_sent, bytes_received, collected_at + FROM client_metrics + WHERE client_id = ? + ORDER BY collected_at DESC + LIMIT 1 + "); + $stmt->execute([$client['id']]); + $previous = $stmt->fetch(PDO::FETCH_ASSOC); + + $speedUp = 0; + $speedDown = 0; + + if ($previous) { + $timeDiff = time() - strtotime($previous['collected_at']); + if ($timeDiff > 0) { + // Calculate speed in Kbps + $bytesDiffSent = (int)$bytesSent - (int)$previous['bytes_sent']; + $bytesDiffReceived = (int)$bytesReceived - (int)$previous['bytes_received']; + + $speedUp = round(($bytesDiffSent * 8) / $timeDiff / 1000, 2); + $speedDown = round(($bytesDiffReceived * 8) / $timeDiff / 1000, 2); + } + } + + return [ + 'bytes_sent' => (int)$bytesSent, + 'bytes_received' => (int)$bytesReceived, + 'speed_up_kbps' => $speedUp, + 'speed_down_kbps' => $speedDown, + ]; + } + + /** + * Save server metrics to database + */ + private function saveServerMetrics(array $metrics): void + { + $db = DB::conn(); + + $stmt = $db->prepare(" + INSERT INTO server_metrics + (server_id, cpu_percent, ram_used_mb, ram_total_mb, disk_used_gb, disk_total_gb, network_rx_mbps, network_tx_mbps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "); + + $stmt->execute([ + $this->serverData['id'], + $metrics['cpu_percent'], + $metrics['ram_used_mb'], + $metrics['ram_total_mb'], + $metrics['disk_used_gb'], + $metrics['disk_total_gb'], + $metrics['network_rx_mbps'], + $metrics['network_tx_mbps'], + ]); + } + + /** + * Save client metrics to database + */ + private function saveClientMetrics(int $clientId, array $stats): void + { + $db = DB::conn(); + + $stmt = $db->prepare(" + INSERT INTO client_metrics + (client_id, bytes_sent, bytes_received, speed_up_kbps, speed_down_kbps) + VALUES (?, ?, ?, ?, ?) + "); + + $stmt->execute([ + $clientId, + $stats['bytes_sent'], + $stats['bytes_received'], + $stats['speed_up_kbps'], + $stats['speed_down_kbps'], + ]); + } + + /** + * Get server metrics for last 24 hours + */ + public static function getServerMetrics(int $serverId, int $hours = 24): array + { + $db = DB::conn(); + + $stmt = $db->prepare(" + SELECT * + FROM server_metrics + WHERE server_id = ? + AND collected_at >= DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY collected_at ASC + "); + + $stmt->execute([$serverId, $hours]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Get client metrics for last 24 hours + */ + public static function getClientMetrics(int $clientId, int $hours = 24): array + { + $db = DB::conn(); + + $stmt = $db->prepare(" + SELECT * + FROM client_metrics + WHERE client_id = ? + AND collected_at >= DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY collected_at ASC + "); + + $stmt->execute([$clientId, $hours]); + + return $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + /** + * Clean old metrics (older than 24 hours) + */ + public static function cleanOldMetrics(): void + { + $db = DB::conn(); + + // Clean server metrics + $db->exec("DELETE FROM server_metrics WHERE collected_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)"); + + // Clean client metrics + $db->exec("DELETE FROM client_metrics WHERE collected_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)"); + } + + /** + * Execute SSH command on server + */ + private function execSSH(string $cmd): ?string + { + $host = $this->serverData['host']; + $port = $this->serverData['port']; + $username = $this->serverData['username']; + $password = $this->serverData['password']; + + $sshCmd = sprintf( + 'sshpass -p %s ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p %d %s@%s %s 2>/dev/null', + escapeshellarg($password), + $port, + escapeshellarg($username), + escapeshellarg($host), + escapeshellarg($cmd) + ); + + $output = shell_exec($sshCmd); + + return $output ?: null; + } +} diff --git a/inc/VpnClient.php b/inc/VpnClient.php index bca4a9d..1cf7732 100644 --- a/inc/VpnClient.php +++ b/inc/VpnClient.php @@ -689,15 +689,11 @@ class VpnClient { } /** - * Format bytes to human-readable string + * Format bytes to human-readable string (always in MB) */ private function formatBytes(int $bytes): string { - if ($bytes === 0) return '0 B'; - - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $i = floor(log($bytes) / log(1024)); - - return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i]; + $mb = $bytes / 1048576; // 1024 * 1024 + return number_format($mb, 2) . ' MB'; } /** diff --git a/migrations/009_add_server_metrics.sql b/migrations/009_add_server_metrics.sql new file mode 100644 index 0000000..d06511c --- /dev/null +++ b/migrations/009_add_server_metrics.sql @@ -0,0 +1,31 @@ +-- Add server metrics tables +-- This migration adds functionality to store and display server monitoring data + +-- Server metrics (CPU, RAM, Disk, Network) +CREATE TABLE IF NOT EXISTS server_metrics ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + server_id INT UNSIGNED NOT NULL, + cpu_percent DECIMAL(5,2) NULL COMMENT 'CPU usage percentage', + ram_used_mb INT UNSIGNED NULL COMMENT 'RAM used in MB', + ram_total_mb INT UNSIGNED NULL COMMENT 'Total RAM in MB', + disk_used_gb DECIMAL(10,2) NULL COMMENT 'Disk used in GB', + disk_total_gb DECIMAL(10,2) NULL COMMENT 'Total disk in GB', + network_rx_mbps DECIMAL(10,2) NULL COMMENT 'Network receive speed in Mbps', + network_tx_mbps DECIMAL(10,2) NULL COMMENT 'Network transmit speed in Mbps', + collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_server_time (server_id, collected_at), + FOREIGN KEY (server_id) REFERENCES vpn_servers(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Client traffic metrics (speed tracking) +CREATE TABLE IF NOT EXISTS client_metrics ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + client_id INT UNSIGNED NOT NULL, + bytes_sent BIGINT UNSIGNED DEFAULT 0 COMMENT 'Bytes sent at this moment', + bytes_received BIGINT UNSIGNED DEFAULT 0 COMMENT 'Bytes received at this moment', + speed_up_kbps DECIMAL(10,2) NULL COMMENT 'Upload speed in Kbps', + speed_down_kbps DECIMAL(10,2) NULL COMMENT 'Download speed in Kbps', + collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_client_time (client_id, collected_at), + FOREIGN KEY (client_id) REFERENCES vpn_clients(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/010_add_monitoring_translations.sql b/migrations/010_add_monitoring_translations.sql new file mode 100644 index 0000000..24dbba2 --- /dev/null +++ b/migrations/010_add_monitoring_translations.sql @@ -0,0 +1,92 @@ +-- Add translations for monitoring UI elements + +INSERT INTO translations (translation_key, language_code, translation_value) VALUES +-- Speed +('common.speed', 'en', 'Speed'), +('common.speed', 'ru', 'Скорость'), +('common.speed', 'es', 'Velocidad'), +('common.speed', 'de', 'Geschwindigkeit'), +('common.speed', 'fr', 'Vitesse'), +('common.speed', 'zh', '速度'), + +-- Metrics +('common.metrics', 'en', 'Metrics'), +('common.metrics', 'ru', 'Метрики'), +('common.metrics', 'es', 'Métricas'), +('common.metrics', 'de', 'Metriken'), +('common.metrics', 'fr', 'Métriques'), +('common.metrics', 'zh', '指标'), + +-- Server Info +('servers.server_info', 'en', 'Server Info'), +('servers.server_info', 'ru', 'Информация о сервере'), +('servers.server_info', 'es', 'Información del servidor'), +('servers.server_info', 'de', 'Serverinformationen'), +('servers.server_info', 'fr', 'Informations sur le serveur'), +('servers.server_info', 'zh', '服务器信息'), + +-- Status +('common.status', 'en', 'Status'), +('common.status', 'ru', 'Статус'), +('common.status', 'es', 'Estado'), +('common.status', 'de', 'Status'), +('common.status', 'fr', 'Statut'), +('common.status', 'zh', '状态'), + +-- Client Configuration +('clients.configuration', 'en', 'Client Configuration'), +('clients.configuration', 'ru', 'Конфигурация клиента'), +('clients.configuration', 'es', 'Configuración del cliente'), +('clients.configuration', 'de', 'Client-Konfiguration'), +('clients.configuration', 'fr', 'Configuration du client'), +('clients.configuration', 'zh', '客户端配置'), + +-- Traffic Statistics +('clients.traffic_stats', 'en', 'Traffic Statistics'), +('clients.traffic_stats', 'ru', 'Статистика трафика'), +('clients.traffic_stats', 'es', 'Estadísticas de tráfico'), +('clients.traffic_stats', 'de', 'Traffic-Statistiken'), +('clients.traffic_stats', 'fr', 'Statistiques de trafic'), +('clients.traffic_stats', 'zh', '流量统计'), + +-- Uploaded +('common.uploaded', 'en', 'Uploaded'), +('common.uploaded', 'ru', 'Отправлено'), +('common.uploaded', 'es', 'Subido'), +('common.uploaded', 'de', 'Hochgeladen'), +('common.uploaded', 'fr', 'Envoyé'), +('common.uploaded', 'zh', '上传'), + +-- Downloaded +('common.downloaded', 'en', 'Downloaded'), +('common.downloaded', 'ru', 'Получено'), +('common.downloaded', 'es', 'Descargado'), +('common.downloaded', 'de', 'Heruntergeladen'), +('common.downloaded', 'fr', 'Reçu'), +('common.downloaded', 'zh', '下载'), + +-- Total +('common.total', 'en', 'Total'), +('common.total', 'ru', 'Всего'), +('common.total', 'es', 'Total'), +('common.total', 'de', 'Gesamt'), +('common.total', 'fr', 'Total'), +('common.total', 'zh', '总计'), + +-- Created +('common.created', 'en', 'Created'), +('common.created', 'ru', 'Создан'), +('common.created', 'es', 'Creado'), +('common.created', 'de', 'Erstellt'), +('common.created', 'fr', 'Créé'), +('common.created', 'zh', '创建时间'), + +-- IP Address +('common.ip_address', 'en', 'IP Address'), +('common.ip_address', 'ru', 'IP-адрес'), +('common.ip_address', 'es', 'Dirección IP'), +('common.ip_address', 'de', 'IP-Adresse'), +('common.ip_address', 'fr', 'Adresse IP'), +('common.ip_address', 'zh', 'IP地址') + +ON DUPLICATE KEY UPDATE translation_value=VALUES(translation_value); diff --git a/public/index.php b/public/index.php index 4adc895..e7335d1 100644 --- a/public/index.php +++ b/public/index.php @@ -19,6 +19,7 @@ require_once __DIR__ . '/../inc/VpnClient.php'; require_once __DIR__ . '/../inc/Translator.php'; require_once __DIR__ . '/../inc/JWT.php'; require_once __DIR__ . '/../inc/PanelImporter.php'; +require_once __DIR__ . '/../inc/ServerMonitoring.php'; // Load environment configuration Config::load(__DIR__ . '/../.env'); @@ -48,6 +49,29 @@ Translator::init(); $user = Auth::user(); $appName = Config::get('APP_NAME', 'Amnezia VPN Panel'); +/** + * Helper function to authenticate user from JWT or session + * Returns user array or null if unauthorized + */ +function authenticateRequest(): ?array { + // Check JWT token in Authorization header + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { + $token = $matches[1]; + $user = JWT::verify($token); + if ($user) { + return $user; + } + } + + // Fallback to session + if (isset($_SESSION['user_id'])) { + return Auth::user(); + } + + return null; +} + View::init(__DIR__ . '/../templates', [ 'app_name' => $appName, 'user' => $user, @@ -421,6 +445,36 @@ Router::get('/servers/{id}', function ($params) { } }); +// Server monitoring page +Router::get('/servers/{id}/monitoring', function ($params) { + requireAuth(); + $serverId = (int)$params['id']; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + $user = Auth::user(); + if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + http_response_code(403); + echo 'Forbidden'; + return; + } + + // Get clients for this server + $clients = VpnClient::listByServer($serverId); + + View::render('servers/monitoring.twig', [ + 'server' => $serverData, + 'clients' => $clients, + ]); + } catch (Exception $e) { + http_response_code(404); + echo 'Server not found'; + } +}); + // Delete server Router::post('/servers/{id}/delete', function ($params) { requireAuth(); @@ -1272,12 +1326,112 @@ Router::post('/api/clients/{id}/restore', function ($params) { } }); +// API: Get server metrics +Router::get('/api/servers/{id}/metrics', function ($params) { + header('Content-Type: application/json'); + + // Check authentication - either JWT or session + $user = null; + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + + if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { + // JWT authentication + $token = $matches[1]; + $user = JWT::verify($token); + } else if (isset($_SESSION['user_id'])) { + // Session authentication + $user = Auth::user(); + } + + if (!$user) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + return; + } + + $serverId = (int)$params['id']; + $hours = isset($_GET['hours']) ? (float)$_GET['hours'] : 24; + + try { + $server = new VpnServer($serverId); + $serverData = $server->getData(); + + // Check ownership + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $metrics = ServerMonitoring::getServerMetrics($serverId, $hours); + + echo json_encode(['success' => true, 'metrics' => $metrics]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + +// API: Get client metrics +Router::get('/api/clients/{id}/metrics', function ($params) { + header('Content-Type: application/json'); + + // Check authentication - either JWT or session + $user = null; + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + + if ($authHeader && preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) { + // JWT authentication + $token = $matches[1]; + $user = JWT::verify($token); + } else if (isset($_SESSION['user_id'])) { + // Session authentication + $user = Auth::user(); + } + + if (!$user) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + return; + } + + $clientId = (int)$params['id']; + $hours = isset($_GET['hours']) ? (float)$_GET['hours'] : 24; + + try { + $client = new VpnClient($clientId); + $clientData = $client->getData(); + + // Get server to check ownership + $server = new VpnServer($clientData['server_id']); + $serverData = $server->getData(); + + // Check ownership + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { + http_response_code(403); + echo json_encode(['error' => 'Forbidden']); + return; + } + + $metrics = ServerMonitoring::getClientMetrics($clientId, $hours); + + echo json_encode(['success' => true, 'metrics' => $metrics]); + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); + } +}); + // API: Get server clients Router::get('/api/servers/{id}/clients', function ($params) { header('Content-Type: application/json'); - $user = JWT::requireAuth(); - if (!$user) return; + $user = authenticateRequest(); + if (!$user) { + http_response_code(401); + echo json_encode(['error' => 'Unauthorized']); + return; + } $serverId = (int)$params['id']; @@ -1286,8 +1440,7 @@ Router::get('/api/servers/{id}/clients', function ($params) { $serverData = $server->getData(); // Check ownership - $user = Auth::user(); - if ($serverData['user_id'] != $user['id'] && !Auth::isAdmin()) { + if ($serverData['user_id'] != $user['id'] && $user['role'] !== 'admin') { http_response_code(403); echo json_encode(['error' => 'Forbidden']); return; diff --git a/templates/clients/view.twig b/templates/clients/view.twig index 770ae78..90a055b 100644 --- a/templates/clients/view.twig +++ b/templates/clients/view.twig @@ -6,19 +6,19 @@
{{ server.host }}
{{ server.host }}
+