[], 'memory' => [], 'network' => [], 'disk' => [], ]; public bool $shouldRenderCharts = false; public bool $networkAnalysisLoading = false; public int $networkAnalysisRetryCount = 0; public int $networkAnalysisMaxRetries = 3; public array $networkAnalysis = [ 'connections' => [], 'topConnections' => [], 'topPorts' => [], 'security' => [], 'lastUpdated' => null, 'error' => null, ]; protected array $cachedServerIps = []; protected array $serverIpCidrs = []; // Historical comparison settings public string $historicalPeriod = '1_hour_ago'; // Options: 1_hour_ago, 2_hours_ago, 6_hours_ago, 12_hours_ago, 24_hours_ago, custom public string $currentPeriod = 'now'; // Options: now, 1_hour_ago, custom public ?string $historicalCustomDate = null; public ?string $currentCustomDate = null; public function getChartEventsProperty() { $tasks = $this->taskHistory; $events = []; foreach ($tasks as $task) { $type = strtolower($task['type'] ?? ''); $status = strtolower($task['status'] ?? ''); $startTime = $task['starttime'] ?? null; $endTime = $task['endtime'] ?? null; $upid = $task['upid'] ?? $task['id'] ?? null; if (!$startTime) { continue; } $event = null; if (in_array($type, ['qmstart', 'qmresume'])) { $event = ['label' => 'Start', 'type' => 'start', 'icon' => 'ri-play-fill']; } elseif (in_array($type, ['qmstop', 'qmshutdown'])) { $event = ['label' => 'Shutdown', 'type' => 'shutdown', 'icon' => 'ri-stop-fill']; } elseif (in_array($type, ['qmreboot'])) { $event = ['label' => 'Reboot', 'type' => 'reboot', 'icon' => 'ri-loop-left-line']; } elseif (in_array($type, ['qmreset'])) { $event = ['label' => 'Reset', 'type' => 'reset', 'icon' => 'ri-restart-line']; } elseif ($type === 'vzdump') { $event = ['label' => 'Backup', 'type' => 'backup', 'icon' => 'ri-save-3-fill']; } if ($event) { $startCarbon = \Carbon\Carbon::createFromTimestamp($startTime); $endCarbon = $endTime && $endTime > 0 ? \Carbon\Carbon::createFromTimestamp($endTime) : null; $duration = null; if ($startCarbon && $endCarbon) { $duration = $startCarbon->diffInSeconds($endCarbon); } elseif ($startCarbon && $status === 'running') { $duration = $startCarbon->diffInSeconds(now()); } $events[] = [ 'timestamp' => $startTime * 1000, // Convert to milliseconds 'label' => $event['label'], 'type' => $event['type'], 'icon' => $event['icon'], 'status' => $status, 'original_type' => $task['type'] ?? 'N/A', 'starttime' => $startTime, 'endtime' => $endTime, 'duration' => $duration, 'upid' => $upid, 'start_datetime' => $startCarbon->format('M d, Y H:i:s'), 'end_datetime' => $endCarbon ? $endCarbon->format('M d, Y H:i:s') : null, 'duration_formatted' => $duration !== null ? $this->formatUptime($duration) : null, ]; } } return $events; } public function mount(Server $server): void { parent::mount($server); $this->server->loadMissing('ipAddresses'); // Network Analysis is loaded lazily via JavaScript to improve page load speed // Initialize error state if not configured to prevent infinite loading if (!$this->coreRouterEnabled()) { $this->networkAnalysis['error'] = 'Core Router API is not configured.'; $this->networkAnalysis['connections'] = []; $this->networkAnalysis['topConnections'] = []; $this->networkAnalysis['topPorts'] = []; $this->networkAnalysis['security'] = []; } // Preload graph data on mount to speed up initial display // This runs in the background while the page is rendering $this->dispatch('proxmox:stats'); } #[On('proxmox:stats')] public function stats(): void { $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); } /** * Explicit event handler for Network Analysis lazy loading. * * This mirrors the Live Telemetry pattern (proxmox:stats) and allows the * frontend to trigger loading via Livewire.dispatch(), which is more * robust than relying on @this in inline scripts – especially on the * first-ever load of the Statistics page for a given server. */ #[On('proxmox:network-analysis-load')] public function handleNetworkAnalysisLoad(): void { $this->loadNetworkAnalysis(); } public function loadNetworkAnalysis(): void { Log::debug('Network Analysis: loadNetworkAnalysis() called', [ 'current_loading_state' => $this->networkAnalysisLoading, 'has_last_updated' => ! empty($this->networkAnalysis['lastUpdated']), 'has_error' => ! empty($this->networkAnalysis['error']), ]); // Use a short-lived cache to avoid repeatedly performing heavy analysis $cacheKey = 'proxmox_network_analysis_' . $this->server->id; // If we already have a fairly recent result, reuse it immediately $cachedAnalysis = Cache::get($cacheKey); if (is_array($cachedAnalysis) && ! empty($cachedAnalysis['lastUpdated']) && empty($this->networkAnalysis['error'])) { Log::debug('Network Analysis: served from cache', [ 'cache_key' => $cacheKey, 'last_updated' => $cachedAnalysis['lastUpdated'] ?? null, ]); $this->networkAnalysis = $cachedAnalysis; $this->networkAnalysisLoading = false; // Reset retry count when we successfully hydrate from cache $this->networkAnalysisRetryCount = 0; return; } // Prevent duplicate loads while a request is already in-flight if ($this->networkAnalysisLoading) { Log::debug('Network Analysis: Already loading, skipping new request'); return; } if (! $this->coreRouterEnabled()) { $this->networkAnalysis['error'] = 'Core Router API is not configured.'; $this->networkAnalysis['connections'] = []; $this->networkAnalysis['topConnections'] = []; $this->networkAnalysis['topPorts'] = []; $this->networkAnalysis['security'] = []; $this->networkAnalysisLoading = false; return; } $serverIps = $this->getServerIps(); if (empty($serverIps)) { $this->networkAnalysis['error'] = 'No IP addresses are associated with this server.'; $this->networkAnalysis['connections'] = []; $this->networkAnalysis['topConnections'] = []; $this->networkAnalysis['topPorts'] = []; $this->networkAnalysis['security'] = []; $this->networkAnalysisLoading = false; return; } $this->networkAnalysisLoading = true; $this->networkAnalysis['error'] = null; try { $client = app(CoreRouterClient::class); // Fetch all firewall states (no API-level filtering) $allStates = $client->listStates(); // Normalize server IPs for matching $normalizedServerIps = array_map(static function ($ip) { return strtolower(trim($ip)); }, $serverIps); // Sample a few states to debug extraction (kept minimal for performance) $sampleStatesForDebug = []; foreach (array_slice($allStates, 0, 10) as $sampleState) { $rawSrc = (string) ($sampleState['source'] ?? ''); $rawDst = (string) ($sampleState['destination'] ?? ''); $sampleStatesForDebug[] = [ 'raw_source' => $rawSrc, 'raw_destination' => $rawDst, 'extracted_source' => $this->extractIpFromField($rawSrc), 'extracted_destination' => $this->extractIpFromField($rawDst), 'protocol' => $sampleState['protocol'] ?? 'N/A', ]; } Log::debug('Core Router filtering states', [ 'total_states' => count($allStates), 'server_ips' => $serverIps, 'normalized_server_ips' => $normalizedServerIps, 'sample_states_extraction' => $sampleStatesForDebug, ]); // Filter states: keep only where source OR destination matches a server IP $filteredStates = []; $sampleChecks = []; foreach ($allStates as $state) { $rawSource = (string) ($state['source'] ?? ''); $rawDestination = (string) ($state['destination'] ?? ''); // Extract IP from "IP[port]" or "IP:port" format $sourceIp = $this->extractIpFromField($rawSource); $destinationIp = $this->extractIpFromField($rawDestination); $sourceNormalized = strtolower(trim($sourceIp)); $destNormalized = strtolower(trim($destinationIp)); $matches = false; $matchReason = ''; // Normalize raw fields for case-insensitive matching $rawSourceLower = strtolower($rawSource); $rawDestinationLower = strtolower($rawDestination); // Check exact match foreach ($normalizedServerIps as $serverIp) { if ($sourceNormalized === $serverIp) { $matches = true; $matchReason = "source exact match: {$sourceNormalized} === {$serverIp}"; break; } if ($destNormalized === $serverIp) { $matches = true; $matchReason = "destination exact match: {$destNormalized} === {$serverIp}"; break; } // Also check raw fields contain the IP (handles "IP[port]" format) - case-insensitive if (stripos($rawSourceLower, $serverIp) !== false) { $matches = true; $matchReason = "raw source contains: {$rawSource} contains {$serverIp}"; break; } if (stripos($rawDestinationLower, $serverIp) !== false) { $matches = true; $matchReason = "raw destination contains: {$rawDestination} contains {$serverIp}"; break; } } if ($matches) { $filteredStates[] = $state; // Log first few matches for debugging if (count($sampleChecks) < 5) { $sampleChecks[] = [ 'raw_source' => $rawSource, 'raw_destination' => $rawDestination, 'extracted_source' => $sourceIp, 'extracted_destination' => $destinationIp, 'source_normalized' => $sourceNormalized, 'dest_normalized' => $destNormalized, 'match_reason' => $matchReason, 'protocol' => $state['protocol'] ?? 'N/A', ]; } } } Log::debug('Core Router filtered states result', [ 'filtered_count' => count($filteredStates), 'sample_matches' => $sampleChecks, ]); // Format connections $connections = $this->formatConnectionsSimple(array_values($filteredStates)); $topConnections = $this->aggregateTopConnections($connections); $topPorts = $this->aggregateTopPorts($connections); $securityWarnings = $this->checkSecurityThreats($connections); Log::debug('Core Router network analysis - final formatting', [ 'filtered_states_count' => count($filteredStates), 'connections_count' => count($connections), 'sample_connections' => array_slice($connections, 0, 3), ]); $analysis = [ 'connections' => array_slice($connections, 0, 100), 'topConnections' => array_slice($topConnections, 0, 10), 'topPorts' => array_slice($topPorts, 0, 10), 'security' => $securityWarnings, 'lastUpdated' => now()->toDateTimeString(), 'error' => null, ]; $this->networkAnalysis = $analysis; // Persist the computed analysis for a short period to speed up subsequent loads Cache::put($cacheKey, $analysis, now()->addSeconds(30)); // Reset retry count on successful load $this->networkAnalysisRetryCount = 0; } catch (\Throwable $e) { report($e); $errorMessage = $e->getMessage(); $is502Error = str_contains($errorMessage, 'HTTP 502') || str_contains($errorMessage, '(HTTP 502)'); // Always initialize error arrays first $this->networkAnalysis['connections'] = []; $this->networkAnalysis['topConnections'] = []; $this->networkAnalysis['topPorts'] = []; $this->networkAnalysis['security'] = []; // On failure, do not leave any stale cache behind for this server Cache::forget($cacheKey); // Check if it's a 502 error and we haven't exceeded max retries if ($is502Error && $this->networkAnalysisRetryCount < $this->networkAnalysisMaxRetries) { $this->networkAnalysisRetryCount++; Log::warning('Core Router API returned 502 error, will retry', [ 'retry_count' => $this->networkAnalysisRetryCount, 'max_retries' => $this->networkAnalysisMaxRetries, ]); // Set error message indicating retry $this->networkAnalysis['error'] = 'Temporary server error (HTTP 502). Retrying... (Attempt ' . $this->networkAnalysisRetryCount . '/' . $this->networkAnalysisMaxRetries . ')'; // Dispatch event to retry after a delay $this->dispatch('network-analysis-502-retry', [ 'retryCount' => $this->networkAnalysisRetryCount, ]); } else { // Not a 502 error or max retries reached - show final error $this->networkAnalysisRetryCount = 0; $this->networkAnalysis['error'] = 'Unable to load network analysis: ' . $errorMessage; } } finally { // Always clear loading state in finally block so UI can recover $this->networkAnalysisLoading = false; } } /** * Retry loading network analysis after a 502 error */ public function retryNetworkAnalysis(): void { // Simple helper that can be invoked from Livewire/JS if needed. // We intentionally avoid blocking sleep here; the browser-side // retry logic already introduces a delay between attempts. $this->networkAnalysisLoading = false; $this->loadNetworkAnalysis(); } /** * Extract IP address from field (handles "IP[port]" and "IP:port" formats) */ protected function extractIpFromField(string $field): string { if (empty($field)) { return ''; } $field = trim($field); // Handle IPv4 "IP:port" format FIRST (e.g., "45.154.99.28:22") // Check this before the bracket format since IPv4 also uses colons if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) { return $matches[1]; } // Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]") // This regex matches: IP part (anything that's not [ or ]) optionally followed by [port] if (preg_match('/^([^\[\]]+)(?:\[[^\]]+\])?$/', $field, $matches)) { $ip = trim($matches[1]); // Remove any trailing colons or brackets $ip = rtrim($ip, ':[]'); return $ip; } // If no special format, return as-is return $field; } /** * Simple connection formatting */ protected function formatConnectionsSimple(array $states): array { $connections = []; foreach ($states as $state) { $rawSource = (string)($state['source'] ?? ''); $rawDestination = (string)($state['destination'] ?? ''); $sourceIp = $this->extractIpFromField($rawSource); $destinationIp = $this->extractIpFromField($rawDestination); // Extract ports $sourcePort = null; $destinationPort = null; if (preg_match('/\[(\d+)\]$/', $rawSource, $matches)) { $sourcePort = (int)$matches[1]; } elseif (preg_match('/:(\d+)$/', $rawSource, $matches) && filter_var(explode(':', $rawSource)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { $sourcePort = (int)$matches[1]; } if (preg_match('/\[(\d+)\]$/', $rawDestination, $matches)) { $destinationPort = (int)$matches[1]; } elseif (preg_match('/:(\d+)$/', $rawDestination, $matches) && filter_var(explode(':', $rawDestination)[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { $destinationPort = (int)$matches[1]; } $bytes = (int)($state['bytes_total'] ?? $state['bytes'] ?? 0); $packets = (int)($state['packets_total'] ?? $state['packets'] ?? 0); $protocol = strtoupper($state['protocol'] ?? 'N/A'); $stateName = strtoupper($state['state'] ?? 'UNKNOWN'); $interface = strtoupper($state['interface'] ?? 'N/A'); $direction = strtolower($state['direction'] ?? '') === 'in' ? 'Inbound' : 'Outbound'; $connections[] = [ 'protocol' => $protocol, 'source' => $sourceIp, 'destination' => $destinationIp, 'source_port' => $sourcePort, 'destination_port' => $destinationPort, 'raw_source' => $rawSource, 'raw_destination' => $rawDestination, 'bytes' => $bytes, 'bytes_human' => $this->formatBytes($bytes), 'packets' => $packets, 'state' => $stateName, 'interface' => $interface, 'direction' => $direction, ]; } usort($connections, fn($a, $b) => $b['bytes'] <=> $a['bytes']); return $connections; } protected function fetchGraphData(string $graphType, string $timeRange, string $aggregation): void { $cacheKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation; try { // Reduced cache TTL from 60 to 30 seconds for fresher data and faster updates $data = Cache::remember($cacheKey, 30, function () use ($timeRange, $aggregation) { $proxmox = new Proxmox; $stats = $proxmox->request('/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/rrddata', data: [ 'timeframe' => $timeRange, 'cf' => $aggregation === 'maximum' ? 'MAX' : 'AVERAGE', ]); $rawData = $stats->json()['data'] ?? []; // Limit data points to prevent memory issues and 413 errors // Always limit to 40 points regardless of time range $maxPoints = 40; // Sample data if it exceeds max points if (count($rawData) > $maxPoints) { $step = ceil(count($rawData) / $maxPoints); $sampledData = []; for ($i = 0; $i < count($rawData); $i += $step) { $sampledData[] = $rawData[$i]; } // Always include the last point if (end($rawData) !== end($sampledData)) { $sampledData[] = end($rawData); } return $sampledData; } return $rawData; }); } catch (\Throwable $e) { report($e); $data = []; } $this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $data); } #[On('render-chart-events')] public function renderChartEvents() { $this->dispatch('proxmox:chart-events', $this->chartEvents); } public function updatedCpuTimeRange(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation); $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); } public function updatedCpuAggregation(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-cpu-' . $this->cpuTimeRange . '-' . $this->cpuAggregation); $this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation); } public function updatedMemoryTimeRange(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation); $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); } public function updatedMemoryAggregation(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-memory-' . $this->memoryTimeRange . '-' . $this->memoryAggregation); $this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation); } public function updatedNetworkTimeRange(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation); $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); } public function updatedNetworkAggregation(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-network-' . $this->networkTimeRange . '-' . $this->networkAggregation); $this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation); } public function updatedDiskTimeRange(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation); $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); } public function updatedDiskAggregation(): void { Cache::forget('proxmox-stats-' . $this->server->id . '-disk-' . $this->diskTimeRange . '-' . $this->diskAggregation); $this->fetchGraphData('disk', $this->diskTimeRange, $this->diskAggregation); } public function showExportInfo(string $message): void { $this->exportInfoMessage = $message; $this->showExportInfoModal = true; } public function closeExportInfoModal(): void { $this->showExportInfoModal = false; } public function openDeleteModal($statisticId) { $this->statisticToDeleteId = $statisticId; $this->showDeleteModal = true; } public function closeDeleteModal() { $this->showDeleteModal = false; $this->statisticToDeleteId = null; } public function selectStatistic($statisticId) { $this->selectedStatisticId = $statisticId; $this->dispatch('statistic-selected'); } public function createLiveStatistic() { try { // Check if user has reached the maximum of 5 manual statistics $manualStatsCount = ServerStatistics::where('server_id', $this->server->id) ->where('is_automatic', false) ->count(); if ($manualStatsCount >= 5) { $this->notify('Maximum limit of 5 manual statistics reached. Please delete an existing manual statistic before creating a new one.', 'error'); return; } $proxmox = new Proxmox; $status = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/current' )->json()['data'] ?? null; if ($status) { // Get disk I/O from rrddata (latest point) $diskRead = 0; $diskWrite = 0; $diskUsed = $status['disk'] ?? 0; $diskMax = $status['maxdisk'] ?? 0; try { $rrdData = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/rrddata', 'get', ['timeframe' => 'hour', 'cf' => 'AVERAGE'] )->json()['data'] ?? null; if ($rrdData && is_array($rrdData) && count($rrdData) > 0) { $latest = end($rrdData); $diskRead = (int)($latest['diskread'] ?? 0); $diskWrite = (int)($latest['diskwrite'] ?? 0); } } catch (\Throwable $e) { // If rrddata fails, continue with 0 values } // Always try to get diskmax from VM config (more reliable than status endpoint) // The status endpoint may not return maxdisk, especially if guest agent is not enabled try { $config = $proxmox->request( '/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config' )->json()['data'] ?? null; if ($config) { // Try to find disk configuration (scsi0, virtio0, sata0, ide0, scsi1, etc.) $diskKeys = ['scsi0', 'scsi1', 'scsi2', 'virtio0', 'virtio1', 'virtio2', 'sata0', 'sata1', 'sata2', 'ide0', 'ide1', 'ide2']; $totalDiskSize = 0; foreach ($diskKeys as $diskKey) { if (isset($config[$diskKey])) { // Parse disk size from format like "local-lvm:vm-100-disk-0,size=32G" $diskConfig = $config[$diskKey]; if (preg_match('/size=(\d+)([TGMK]?)/i', $diskConfig, $matches)) { $size = (int)$matches[1]; $unit = strtoupper($matches[2] ?? 'G'); // Convert to bytes switch ($unit) { case 'T': $totalDiskSize += $size * 1024 * 1024 * 1024 * 1024; break; case 'G': $totalDiskSize += $size * 1024 * 1024 * 1024; break; case 'M': $totalDiskSize += $size * 1024 * 1024; break; case 'K': $totalDiskSize += $size * 1024; break; default: $totalDiskSize += $size * 1024 * 1024 * 1024; // Default to GB } } } } // Use config disk size if we found it, otherwise keep status value if ($totalDiskSize > 0) { $diskMax = $totalDiskSize; } } } catch (\Throwable $e) { // If config fetch fails, continue with status value } $statistic = ServerStatistics::create([ 'server_id' => $this->server->id, 'netin' => $status['netin'] ?? 0, 'netout' => $status['netout'] ?? 0, 'diskread' => $diskRead, 'diskwrite' => $diskWrite, 'diskused' => $diskUsed, 'diskmax' => $diskMax, 'memoryused' => $status['mem'] ?? 0, 'memorymax' => $status['maxmem'] ?? 0, 'cpuused' => $status['cpu'] ?? 0, 'cpumax' => 1.0, // CPU max is typically 1.0 (100%) 'uptime' => $status['uptime'] ?? 0, 'vmstatus' => $status['status'] ?? null, 'DateTime' => now(), 'is_automatic' => false, // Manual statistics are not automatic ]); // Auto-select the newly created statistic $this->selectedStatisticId = $statistic->id; $this->dispatch('statistic-selected'); $this->notify('Statistics created successfully', 'success'); } else { $this->notify('Failed to retrieve server status from Proxmox', 'error'); } } catch (\Throwable $e) { $this->notify('Error creating statistics: ' . $e->getMessage(), 'error'); } } public function getSelectedStatisticProperty() { if (!$this->selectedStatisticId) { return null; } return ServerStatistics::where('id', $this->selectedStatisticId) ->where('server_id', $this->server->id) ->first(); } public function getStatisticsProperty() { return ServerStatistics::where('server_id', $this->server->id) ->orderBy('DateTime', 'desc') ->paginate(50); } public function confirmDeleteStatistic() { if (!$this->statisticToDeleteId) { return; } try { $statistic = ServerStatistics::where('id', $this->statisticToDeleteId) ->where('server_id', $this->server->id) ->first(); if (!$statistic) { $this->notify('Statistics entry not found', 'error'); $this->closeDeleteModal(); return; } // Clear selection if this is the selected statistic if ($this->selectedStatisticId == $this->statisticToDeleteId) { $this->selectedStatisticId = null; } $statistic->delete(); $this->notify('Statistics entry deleted successfully', 'success'); $this->closeDeleteModal(); } catch (\Throwable $e) { $this->notify('Error deleting statistics: ' . $e->getMessage(), 'error'); $this->closeDeleteModal(); } } public function openClearStatisticsModal(): void { $this->showClearStatisticsModal = true; } public function closeClearStatisticsModal(): void { $this->showClearStatisticsModal = false; } public function clearStatisticsHistory() { try { ServerStatistics::where('server_id', $this->server->id)->delete(); $this->selectedStatisticId = null; $this->statisticToDeleteId = null; $this->showDeleteModal = false; $this->showClearStatisticsModal = false; $this->notify('Statistics history cleared successfully', 'success'); } catch (\Throwable $e) { $this->notify('Error clearing statistics history: ' . $e->getMessage(), 'error'); } } public function openClearTaskHistoryModal(): void { $this->showClearTaskHistoryModal = true; } public function closeClearTaskHistoryModal(): void { $this->showClearTaskHistoryModal = false; } public function clearTaskHistory() { try { // Clear any cached task history $cacheKey = 'proxmox_task_history_' . $this->server->id; Cache::forget($cacheKey); // Clear expanded task upids $this->expandedTaskUpids = []; $this->showClearTaskHistoryModal = false; $this->notify('Task history cache cleared successfully', 'success'); } catch (\Throwable $e) { $this->notify('Error clearing task history: ' . $e->getMessage(), 'error'); } } public function getStatisticToDeleteProperty() { if (!$this->statisticToDeleteId) { return null; } return ServerStatistics::where('id', $this->statisticToDeleteId) ->where('server_id', $this->server->id) ->first(); } public function getCurrentNetworkStats($statistic) { // Get the previous statistic to calculate the difference $previous = ServerStatistics::where('server_id', $this->server->id) ->where('DateTime', '<', $statistic->DateTime) ->orderBy('DateTime', 'desc') ->first(); if (!$previous) { // No previous statistic, return current values as 0 return [ 'netin' => 0, 'netout' => 0, 'netin_rate' => 0, 'netout_rate' => 0, ]; } // Calculate the difference $netinDiff = max(0, $statistic->netin - $previous->netin); $netoutDiff = max(0, $statistic->netout - $previous->netout); // Calculate time difference in seconds $timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime); if ($timeDiff == 0) { $timeDiff = 1; // Avoid division by zero } // Calculate rate (bytes per second) $netinRate = $netinDiff / $timeDiff; $netoutRate = $netoutDiff / $timeDiff; return [ 'netin' => $netinDiff, 'netout' => $netoutDiff, 'netin_rate' => $netinRate, 'netout_rate' => $netoutRate, ]; } public function getCurrentDiskStats($statistic) { // Get the previous statistic to calculate the difference $previous = ServerStatistics::where('server_id', $this->server->id) ->where('DateTime', '<', $statistic->DateTime) ->orderBy('DateTime', 'desc') ->first(); if (!$previous) { // No previous statistic, return current values as 0 return [ 'diskread' => 0, 'diskwrite' => 0, 'diskread_rate' => 0, 'diskwrite_rate' => 0, ]; } // Calculate the difference (handle null values) $currentDiskread = $statistic->diskread ?? 0; $previousDiskread = $previous->diskread ?? 0; $currentDiskwrite = $statistic->diskwrite ?? 0; $previousDiskwrite = $previous->diskwrite ?? 0; $diskreadDiff = max(0, $currentDiskread - $previousDiskread); $diskwriteDiff = max(0, $currentDiskwrite - $previousDiskwrite); // Calculate time difference in seconds $timeDiff = $statistic->DateTime->diffInSeconds($previous->DateTime); if ($timeDiff == 0) { $timeDiff = 1; // Avoid division by zero } // Calculate rate (bytes per second) $diskreadRate = $diskreadDiff / $timeDiff; $diskwriteRate = $diskwriteDiff / $timeDiff; return [ 'diskread' => $diskreadDiff, 'diskwrite' => $diskwriteDiff, 'diskread_rate' => $diskreadRate, 'diskwrite_rate' => $diskwriteRate, ]; } public function formatUptime($seconds) { if ($seconds < 60) { return $seconds . 's'; } elseif ($seconds < 3600) { return floor($seconds / 60) . 'm ' . ($seconds % 60) . 's'; } elseif ($seconds < 86400) { $hours = floor($seconds / 3600); $minutes = floor(($seconds % 3600) / 60); return $hours . 'h ' . $minutes . 'm'; } else { $days = floor($seconds / 86400); $hours = floor(($seconds % 86400) / 3600); return $days . 'd ' . $hours . 'h'; } } public function getLatestStatProperty() { return ServerStatistics::where('server_id', $this->server->id) ->orderBy('DateTime', 'desc') ->first(); } public function getTaskHistoryProperty() { try { $proxmox = new Proxmox; $vmId = (string)$this->server->vm_id; $allTasks = []; // Try cluster/tasks endpoint first (includes all tasks across nodes) // Note: cluster/tasks doesn't support vmid filter, so we'll filter manually try { $clusterResponse = $proxmox->request('/cluster/tasks', 'get', []); $clusterTasks = $clusterResponse->json()['data'] ?? []; if (is_array($clusterTasks)) { $allTasks = array_merge($allTasks, $clusterTasks); } } catch (\Throwable $e) { // Cluster endpoint might not be available, continue with node endpoint \Log::debug('Cluster tasks endpoint failed: ' . $e->getMessage()); } // Also try node-specific tasks endpoint with vmid filter try { $nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', [ 'vmid' => (int)$vmId, // Filter by VM ID ]); $nodeTasks = $nodeResponse->json()['data'] ?? []; if (is_array($nodeTasks)) { $allTasks = array_merge($allTasks, $nodeTasks); } } catch (\Throwable $e) { // Node endpoint might fail, try without vmid filter as fallback try { $nodeResponse = $proxmox->request('/nodes/' . $this->server->node->name . '/tasks', 'get', []); $nodeTasks = $nodeResponse->json()['data'] ?? []; if (is_array($nodeTasks)) { $allTasks = array_merge($allTasks, $nodeTasks); } } catch (\Throwable $e2) { \Log::debug('Node tasks endpoint failed: ' . $e2->getMessage()); } } // Remove duplicates based on UPID $uniqueTasks = []; $seenUpids = []; foreach ($allTasks as $task) { $upid = $task['upid'] ?? $task['id'] ?? null; if ($upid && !in_array($upid, $seenUpids)) { $seenUpids[] = $upid; $uniqueTasks[] = $task; } elseif (!$upid) { // If no UPID, include it anyway $uniqueTasks[] = $task; } } $allTasks = $uniqueTasks; // If no tasks, return empty if (empty($allTasks)) { return []; } // Filter tasks for this VM // UPID format: UPID:node:type:pid:user:starttime:extratype:vmid // The VMID is the last component after the last colon $tasks = []; foreach ($allTasks as $task) { $isVmTask = false; // Check if task has vmid field directly (as integer or string) if (isset($task['vmid'])) { $taskVmid = (string)$task['vmid']; if ($taskVmid === $vmId) { $isVmTask = true; } } // Check UPID format - VMID is the last component // UPID format: UPID:node:type:pid:user:starttime:extratype:vmid if (!$isVmTask && isset($task['upid']) && is_string($task['upid'])) { $upid = $task['upid']; // Extract VMID from UPID - it's the last part after the last colon $lastColonPos = strrpos($upid, ':'); if ($lastColonPos !== false) { $taskVmid = substr($upid, $lastColonPos + 1); if ($taskVmid === $vmId) { $isVmTask = true; } } // Also try parsing by splitting (in case format is different) if (!$isVmTask) { $parts = explode(':', $upid); if (count($parts) >= 8) { // Last part (index count-1) is the vmid $taskVmid = end($parts); if ($taskVmid === $vmId) { $isVmTask = true; } } } } // Check if task id contains the vmid if (!$isVmTask && isset($task['id'])) { $taskId = (string)$task['id']; // Check if ID ends with the VMID or contains it if (str_ends_with($taskId, ':' . $vmId) || str_contains($taskId, ':' . $vmId . ':') || $taskId === $vmId) { $isVmTask = true; } } // Check if node matches and type is qemu (VM-related task) if (!$isVmTask && isset($task['node']) && $task['node'] === $this->server->node->name) { $taskType = strtolower($task['type'] ?? ''); // QEMU tasks are VM-related if ($taskType === 'qemu' || $taskType === 'qm') { // For qemu tasks, check UPID for vmid if (isset($task['upid']) && is_string($task['upid'])) { $lastColonPos = strrpos($task['upid'], ':'); if ($lastColonPos !== false) { $taskVmid = substr($task['upid'], $lastColonPos + 1); if ($taskVmid === $vmId) { $isVmTask = true; } } } } } if ($isVmTask) { $tasks[] = $task; } } // Sort by starttime descending (newest first) usort($tasks, function($a, $b) { $timeA = $a['starttime'] ?? 0; $timeB = $b['starttime'] ?? 0; return $timeB <=> $timeA; }); // Log for debugging if no tasks found if (empty($tasks)) { if (!empty($allTasks)) { \Log::warning('Task history: Found ' . count($allTasks) . ' total tasks, but none matched VM ID ' . $vmId . ' for server ' . $this->server->id, [ 'vm_id' => $vmId, 'node' => $this->server->node->name ?? 'N/A', 'sample_tasks' => array_map(function($task) { return [ 'type' => $task['type'] ?? 'N/A', 'vmid' => $task['vmid'] ?? 'N/A', 'upid' => $task['upid'] ?? 'N/A', 'node' => $task['node'] ?? 'N/A', ]; }, array_slice($allTasks, 0, 5)), ]); } else { \Log::info('Task history: No tasks found from API for server ' . $this->server->id . ' (VM ID: ' . $vmId . ')'); } } else { \Log::debug('Task history: Found ' . count($tasks) . ' tasks for VM ID ' . $vmId); } // Limit to last 100 tasks return array_slice($tasks, 0, 100); } catch (\Throwable $e) { // Log error for debugging but return empty array \Log::error('Error fetching task history for server ' . $this->server->id . ': ' . $e->getMessage()); return []; } } public function getTaskDetails($taskId) { try { $proxmox = new Proxmox; // Use the /log endpoint to get task log details $logResponse = $proxmox->request( '/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/log' ); $logData = $logResponse->json()['data'] ?? null; // Also get status for additional information $statusData = null; try { $statusResponse = $proxmox->request( '/nodes/' . $this->server->node->name . '/tasks/' . $taskId . '/status' ); $statusData = $statusResponse->json()['data'] ?? null; } catch (\Throwable $e) { // Status endpoint might fail, continue with log data only } // Combine log and status data $taskDetails = []; if ($logData) { $taskDetails['log'] = $logData; } if ($statusData) { $taskDetails = array_merge($taskDetails, $statusData); } if (!empty($taskDetails)) { // Sanitize the task details $taskDetails = $this->sanitizeTaskData($taskDetails); } return $taskDetails; } catch (\Throwable $e) { \Log::error('Error fetching task details: ' . $e->getMessage()); return null; } } public function sanitizeTaskData($data) { if (is_array($data)) { $sanitized = []; foreach ($data as $key => $value) { $keyLower = strtolower($key); // Format log output to be human readable if ($keyLower === 'log' && is_array($value)) { $sanitized[$key] = $this->formatTaskLog($value); continue; } // Sanitize username fields if (in_array($keyLower, ['user', 'username'])) { $sanitized[$key] = '[REDACTED]'; } // Sanitize VMID if it matches this server's VMID elseif ($keyLower === 'vmid' && $value == $this->server->vm_id) { $sanitized[$key] = '[REDACTED]'; } // Recursively sanitize nested arrays elseif (is_array($value)) { $sanitized[$key] = $this->sanitizeTaskData($value); } // Sanitize VMID in string paths elseif (is_string($value) && preg_match('/\/qemu\/(\d+)/', $value)) { $sanitized[$key] = preg_replace('/\/qemu\/\d+/', '/qemu/[REDACTED]', $value); } // Sanitize VMID in UPID format (UPID:node:type:pid:user:starttime:extratype:vmid) elseif (is_string($value) && preg_match('/^UPID:([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):([^:]+):(\d+)$/', $value, $matches)) { $sanitized[$key] = preg_replace('/:(\d+)$/', ':[REDACTED]', $value); } else { $sanitized[$key] = $value; } } return $sanitized; } return $data; } protected function formatTaskLog(array $entries): string { $lines = []; foreach ($entries as $entry) { if (is_array($entry)) { $text = isset($entry['t']) ? trim((string) $entry['t']) : trim(json_encode($entry)); if ($text === '') { $text = '[no output]'; } $lines[] = $text; } else { $lines[] = trim((string) $entry); } } return implode(PHP_EOL, $lines); } public function getChartDataProperty() { // Get last 50 statistics for chart display $stats = ServerStatistics::where('server_id', $this->server->id) ->orderBy('DateTime', 'asc') ->limit(50) ->get(); $data = [ 'dates' => [], 'cpu' => [], 'memory' => [], 'disk' => [], 'networkIn' => [], 'networkOut' => [], ]; $previousStat = null; foreach ($stats as $stat) { $data['dates'][] = $stat->DateTime->format('M d H:i'); $data['cpu'][] = round($stat->cpumax > 0 ? ($stat->cpuused / $stat->cpumax * 100) : 0, 2); $data['memory'][] = round($stat->memorymax > 0 ? ($stat->memoryused / $stat->memorymax * 100) : 0, 2); $data['disk'][] = round(($stat->diskmax ?? 0) > 0 ? (($stat->diskused ?? 0) / ($stat->diskmax ?? 1) * 100) : 0, 2); // Calculate network rates if ($previousStat) { $netinDiff = max(0, $stat->netin - $previousStat->netin); $netoutDiff = max(0, $stat->netout - $previousStat->netout); $timeDiff = $stat->DateTime->diffInSeconds($previousStat->DateTime); if ($timeDiff == 0) $timeDiff = 1; $data['networkIn'][] = round($netinDiff / $timeDiff, 2); $data['networkOut'][] = round($netoutDiff / $timeDiff, 2); } else { $data['networkIn'][] = 0; $data['networkOut'][] = 0; } $previousStat = $stat; } return $data; } public function expandTask($upid) { if (!in_array($upid, $this->expandedTaskUpids)) { $this->expandedTaskUpids[] = $upid; } } public function collapseTask($upid) { $this->expandedTaskUpids = array_values(array_filter($this->expandedTaskUpids, function($id) use ($upid) { return $id !== $upid; })); } public function getHistoricalComparisonProperty() { try { // Get current period statistics with its previous record (for rate calculation) $currentStats = $this->getStatisticsForPeriodWithPrevious($this->currentPeriod, $this->currentCustomDate); // Get historical period statistics with its previous record $historicalStats = $this->getStatisticsForPeriodWithPrevious($this->historicalPeriod, $this->historicalCustomDate); // Check if we have both current and historical stats if (!$currentStats || !$currentStats['stat']) { return [ 'error' => 'current', 'message' => 'No statistics found for the selected current period (' . $this->currentPeriod . ').', 'total_stats' => ServerStatistics::where('server_id', $this->server->id)->count(), ]; } if (!$historicalStats || !$historicalStats['stat']) { return [ 'error' => 'historical', 'message' => 'No statistics found for the selected historical period (' . $this->historicalPeriod . '). Try selecting a different time period.', 'total_stats' => ServerStatistics::where('server_id', $this->server->id)->count(), ]; } $current = $currentStats['stat']; $historical = $historicalStats['stat']; $currentPrevious = $currentStats['previous']; $historicalPrevious = $historicalStats['previous']; // Calculate differences and percentages $comparison = [ 'current' => $current, 'historical' => $historical, 'differences' => [ 'cpu' => $this->calculateDifference($current->cpuused, $current->cpumax, $historical->cpuused, $historical->cpumax), 'memory' => $this->calculateDifference($current->memoryused, $current->memorymax, $historical->memoryused, $historical->memorymax), 'disk' => $this->calculateDifference($current->diskused ?? 0, $current->diskmax ?? 0, $historical->diskused ?? 0, $historical->diskmax ?? 0), 'network_in' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'netin'), 'network_out' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'netout'), 'disk_read' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'diskread'), 'disk_write' => $this->calculateRateDifferenceOptimized($current, $historical, $currentPrevious, $historicalPrevious, 'diskwrite'), ], ]; return $comparison; } catch (\Throwable $e) { \Log::error('Error getting historical comparison: ' . $e->getMessage()); return [ 'error' => 'exception', 'message' => 'An error occurred while loading comparison data: ' . $e->getMessage(), ]; } } /** * Get statistics for a period along with the previous record (for rate calculation) * This reduces queries from 10+ to just 4 total */ protected function getStatisticsForPeriodWithPrevious(string $period, ?string $customDate = null): ?array { $stat = $this->getStatisticsForPeriod($period, $customDate); if (!$stat) { return null; } // Get the previous record for rate calculation $previous = ServerStatistics::where('server_id', $this->server->id) ->where('DateTime', '<', $stat->DateTime) ->orderBy('DateTime', 'desc') ->first(); return [ 'stat' => $stat, 'previous' => $previous, ]; } protected function getStatisticsForPeriod(string $period, ?string $customDate = null) { $now = now(); $targetTime = null; switch ($period) { case 'now': // Get the most recent statistic return ServerStatistics::where('server_id', $this->server->id) ->orderBy('DateTime', 'desc') ->first(); case '1_hour_ago': $targetTime = $now->copy()->subHour(); break; case '2_hours_ago': $targetTime = $now->copy()->subHours(2); break; case '6_hours_ago': $targetTime = $now->copy()->subHours(6); break; case '12_hours_ago': $targetTime = $now->copy()->subHours(12); break; case '24_hours_ago': $targetTime = $now->copy()->subDay(); break; case 'custom': if ($customDate) { try { $targetTime = \Carbon\Carbon::parse($customDate); } catch (\Exception $e) { return null; } } else { return null; } break; default: return null; } if ($targetTime) { // Find the statistic closest to the target time (within 30 minutes) // Use database-agnostic approach $minTime = $targetTime->copy()->subMinutes(30); $maxTime = $targetTime->copy()->addMinutes(30); $stats = ServerStatistics::where('server_id', $this->server->id) ->whereBetween('DateTime', [$minTime, $maxTime]) ->orderBy('DateTime', 'asc') ->get(); if ($stats->isEmpty()) { return null; } // Find the closest one to target time (database-agnostic) $closest = null; $smallestDiff = PHP_INT_MAX; foreach ($stats as $stat) { $diff = abs($stat->DateTime->diffInSeconds($targetTime)); if ($diff < $smallestDiff) { $smallestDiff = $diff; $closest = $stat; } } return $closest; } return null; } protected function calculateDifference($currentValue, $currentMax, $historicalValue, $historicalMax) { $currentPercent = $currentMax > 0 ? ($currentValue / $currentMax * 100) : 0; $historicalPercent = $historicalMax > 0 ? ($historicalValue / $historicalMax * 100) : 0; $absoluteDiff = $currentValue - $historicalValue; $percentDiff = $currentPercent - $historicalPercent; $percentChange = $historicalPercent > 0 ? (($currentPercent - $historicalPercent) / $historicalPercent * 100) : 0; return [ 'current_value' => $currentValue, 'current_percent' => round($currentPercent, 2), 'historical_value' => $historicalValue, 'historical_percent' => round($historicalPercent, 2), 'absolute_diff' => $absoluteDiff, 'percent_diff' => round($percentDiff, 2), 'percent_change' => round($percentChange, 2), ]; } /** * Calculate rate difference using pre-fetched previous stats (optimized) * This eliminates N+1 queries - previous stats are fetched once per period */ protected function calculateRateDifferenceOptimized($currentStat, $historicalStat, $currentPrevious, $historicalPrevious, $field) { $currentRate = 0; $historicalRate = 0; if ($currentPrevious && $currentStat) { $timeDiff = $currentStat->DateTime->diffInSeconds($currentPrevious->DateTime); if ($timeDiff > 0) { $currentDiff = max(0, ($currentStat->$field ?? 0) - ($currentPrevious->$field ?? 0)); $currentRate = $currentDiff / $timeDiff; } } if ($historicalPrevious && $historicalStat) { $timeDiff = $historicalStat->DateTime->diffInSeconds($historicalPrevious->DateTime); if ($timeDiff > 0) { $historicalDiff = max(0, ($historicalStat->$field ?? 0) - ($historicalPrevious->$field ?? 0)); $historicalRate = $historicalDiff / $timeDiff; } } $absoluteDiff = $currentRate - $historicalRate; $percentChange = $historicalRate > 0 ? (($currentRate - $historicalRate) / $historicalRate * 100) : ($currentRate > 0 ? 100 : 0); return [ 'current_rate' => $currentRate, 'historical_rate' => $historicalRate, 'absolute_diff' => $absoluteDiff, 'percent_change' => round($percentChange, 2), ]; } /** * @deprecated Use calculateRateDifferenceOptimized instead */ protected function calculateRateDifference($currentStat, $historicalStat, $field) { // Get previous stats to calculate rates $currentPrevious = ServerStatistics::where('server_id', $this->server->id) ->where('DateTime', '<', $currentStat->DateTime) ->orderBy('DateTime', 'desc') ->first(); $historicalPrevious = ServerStatistics::where('server_id', $this->server->id) ->where('DateTime', '<', $historicalStat->DateTime) ->orderBy('DateTime', 'desc') ->first(); return $this->calculateRateDifferenceOptimized($currentStat, $historicalStat, $currentPrevious, $historicalPrevious, $field); } public function getHistoricalComparisonExportDataProperty() { $comparison = $this->historicalComparison; if (!$comparison) { return []; } $current = $comparison['current']; $historical = $comparison['historical']; $diff = $comparison['differences']; $historicalDateTime = $historical->DateTime->format('Y-m-d H:i:s'); $currentDateTime = $current->DateTime->format('Y-m-d H:i:s'); return [ [ 'metric' => 'CPU Usage', 'historical_datetime' => $historicalDateTime, 'current_datetime' => $currentDateTime, 'current_value' => round($diff['cpu']['current_percent'], 2) . '%', 'historical_value' => round($diff['cpu']['historical_percent'], 2) . '%', 'difference' => round($diff['cpu']['percent_diff'], 2) . '%', 'percent_change' => round($diff['cpu']['percent_change'], 2) . '%', ], [ 'metric' => 'Memory Usage', 'historical_datetime' => $historicalDateTime, 'current_datetime' => $currentDateTime, 'current_value' => round($diff['memory']['current_percent'], 2) . '%', 'historical_value' => round($diff['memory']['historical_percent'], 2) . '%', 'difference' => round($diff['memory']['percent_diff'], 2) . '%', 'percent_change' => round($diff['memory']['percent_change'], 2) . '%', ], [ 'metric' => 'Disk Usage', 'historical_datetime' => $historicalDateTime, 'current_datetime' => $currentDateTime, 'current_value' => round($diff['disk']['current_percent'], 2) . '%', 'historical_value' => round($diff['disk']['historical_percent'], 2) . '%', 'difference' => round($diff['disk']['percent_diff'], 2) . '%', 'percent_change' => round($diff['disk']['percent_change'], 2) . '%', ], [ 'metric' => 'Network In Rate', 'historical_datetime' => $historicalDateTime, 'current_datetime' => $currentDateTime, 'current_value' => $this->formatBytes($diff['network_in']['current_rate']) . '/s', 'historical_value' => $this->formatBytes($diff['network_in']['historical_rate']) . '/s', 'difference' => $this->formatBytes($diff['network_in']['absolute_diff']) . '/s', 'percent_change' => round($diff['network_in']['percent_change'], 2) . '%', ], [ 'metric' => 'Network Out Rate', 'historical_datetime' => $historicalDateTime, 'current_datetime' => $currentDateTime, 'current_value' => $this->formatBytes($diff['network_out']['current_rate']) . '/s', 'historical_value' => $this->formatBytes($diff['network_out']['historical_rate']) . '/s', 'difference' => $this->formatBytes($diff['network_out']['absolute_diff']) . '/s', 'percent_change' => round($diff['network_out']['percent_change'], 2) . '%', ], [ 'metric' => 'Disk Read Rate', 'historical_datetime' => $historicalDateTime, 'current_datetime' => $currentDateTime, 'current_value' => $this->formatBytes($diff['disk_read']['current_rate']) . '/s', 'historical_value' => $this->formatBytes($diff['disk_read']['historical_rate']) . '/s', 'difference' => $this->formatBytes($diff['disk_read']['absolute_diff']) . '/s', 'percent_change' => round($diff['disk_read']['percent_change'], 2) . '%', ], [ 'metric' => 'Disk Write Rate', 'historical_datetime' => $historicalDateTime, 'current_datetime' => $currentDateTime, 'current_value' => $this->formatBytes($diff['disk_write']['current_rate']) . '/s', 'historical_value' => $this->formatBytes($diff['disk_write']['historical_rate']) . '/s', 'difference' => $this->formatBytes($diff['disk_write']['absolute_diff']) . '/s', 'percent_change' => round($diff['disk_write']['percent_change'], 2) . '%', ], ]; } public function exportHistoricalComparison() { $data = $this->historicalComparisonExportData; if (empty($data)) { $this->showExportInfo('No historical comparison data available to export. Please ensure comparison data is displayed before exporting.'); return; } $csv = 'Metric,Historical Date/Time,Current Date/Time,Historical Value,Current Value,Difference,Percent Change' . PHP_EOL; foreach ($data as $row) { $csv .= sprintf( '"%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL, str_replace('"', '""', $row['metric'] ?? ''), str_replace('"', '""', $row['historical_datetime'] ?? ''), str_replace('"', '""', $row['current_datetime'] ?? ''), str_replace('"', '""', $row['historical_value'] ?? ''), str_replace('"', '""', $row['current_value'] ?? ''), str_replace('"', '""', $row['difference'] ?? ''), str_replace('"', '""', $row['percent_change'] ?? '') ); } $filename = 'historical-comparison-' . date('Y-m-d') . '.csv'; return response()->streamDownload(function () use ($csv) { echo $csv; }, $filename, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', ]); } public function exportNetworkAnalysis() { $connections = $this->networkAnalysis['connections'] ?? []; if (empty($connections)) { $this->showExportInfo('No network analysis data available to export. Please refresh the network analysis first.'); return; } $csv = 'Direction,Protocol,Source,Source Port,Destination,Destination Port,State,Bytes,Packets,Bytes (Human)' . PHP_EOL; foreach ($connections as $connection) { $csv .= sprintf( '"%s","%s","%s","%s","%s","%s","%s","%s","%s","%s"' . PHP_EOL, str_replace('"', '""', $connection['direction'] ?? ''), str_replace('"', '""', $connection['protocol'] ?? ''), str_replace('"', '""', $connection['source'] ?? ''), str_replace('"', '""', (string)($connection['source_port'] ?? '')), str_replace('"', '""', $connection['destination'] ?? ''), str_replace('"', '""', (string)($connection['destination_port'] ?? '')), str_replace('"', '""', $connection['state'] ?? ''), str_replace('"', '""', (string)($connection['bytes'] ?? 0)), str_replace('"', '""', (string)($connection['packets'] ?? 0)), str_replace('"', '""', $connection['bytes_human'] ?? '') ); } $filename = 'network-analysis-' . date('Y-m-d_His') . '.csv'; return response()->streamDownload(function () use ($csv) { echo $csv; }, $filename, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', ]); } public function formatBytes($bytes) { if ($bytes == 0 || $bytes < 0) return '0 B'; $k = 1024; $sizes = ['B', 'KB', 'MB', 'GB', 'TB']; $i = floor(log($bytes) / log($k)); if ($i < 0) $i = 0; if ($i >= count($sizes)) $i = count($sizes) - 1; return round($bytes / pow($k, $i), 2) . ' ' . $sizes[$i]; } public function getGraphData(string $type): array { $fullData = $this->graphPayloads[$type] ?? []; // Filter data to only return essential fields for this chart type to reduce response size $filteredData = []; foreach ($fullData as $point) { $filteredPoint = ['time' => $point['time'] ?? 0]; switch ($type) { case 'cpu': $filteredPoint['cpu'] = $point['cpu'] ?? 0; break; case 'memory': $filteredPoint['mem'] = $point['mem'] ?? 0; $filteredPoint['maxmem'] = $point['maxmem'] ?? 0; break; case 'network': $filteredPoint['netin'] = $point['netin'] ?? 0; $filteredPoint['netout'] = $point['netout'] ?? 0; break; case 'disk': $filteredPoint['diskread'] = $point['diskread'] ?? 0; $filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0; break; } $filteredData[] = $filteredPoint; } return $filteredData; } protected function coreRouterEnabled(): bool { $baseUrl = config('corerouter.base_url'); $apiKey = config('corerouter.api_key'); $username = config('corerouter.username'); $password = config('corerouter.password'); // Check if base_url is configured and not empty $hasBaseUrl = !empty(trim((string) $baseUrl)); // Check if API key is configured (not empty after trimming) $hasKey = !empty(trim((string) $apiKey)); // Check if basic auth credentials are both configured $hasBasic = !empty(trim((string) $username)) && !empty(trim((string) $password)); // Configuration is valid if base_url is set and at least one auth method is configured $configured = $hasBaseUrl && ($hasKey || $hasBasic); if (!$configured) { Log::debug('Core Router configuration check', [ 'base_url' => $baseUrl ?: '(empty)', 'base_url_configured' => $hasBaseUrl, 'api_key_present' => $hasKey, 'username_present' => !empty(trim((string) $username)), 'password_present' => !empty(trim((string) $password)), 'basic_auth_configured' => $hasBasic, 'configured' => $configured, ]); } return $configured; } protected function getServerIps(): array { if (!empty($this->cachedServerIps)) { return $this->cachedServerIps; } $this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']); $rawValues = collect(); $primaryIpv4Value = $this->server->primaryIpv4?->ip ?? (is_string($this->server->primary_ipv4) ? $this->server->primary_ipv4 : null); $primaryIpv6Value = $this->server->primaryIpv6?->ip ?? (is_string($this->server->primary_ipv6) ? $this->server->primary_ipv6 : null); if ($primaryIpv4Value) { $rawValues->push($primaryIpv4Value); } if ($primaryIpv6Value) { $rawValues->push($primaryIpv6Value); } $rawValues = $rawValues->merge($this->server->ipAddresses->pluck('ip')); $exactIps = []; $cidrs = []; foreach ($rawValues->filter() as $value) { foreach ($this->expandIpValue($value) as $parsed) { if ($parsed['type'] === 'cidr') { $cidrs[] = $parsed['value']; } elseif ($parsed['type'] === 'ip') { // Normalize IP to lowercase for consistent matching $normalized = $this->normalizeSingleIp($parsed['value']); if ($normalized) { $exactIps[] = $normalized; // For IPv6 addresses, automatically add the /64 subnet to CIDR list if (str_contains($normalized, ':')) { // Extract the /64 network prefix for IPv6 $ipBinary = @inet_pton($normalized); if ($ipBinary !== false && strlen($ipBinary) === 16) { // Zero out the last 64 bits (last 8 bytes) to get the /64 network $networkBinary = substr($ipBinary, 0, 8) . str_repeat("\0", 8); $networkIp = @inet_ntop($networkBinary); if ($networkIp !== false) { $cidrs[] = $networkIp . '/64'; } } } // For IPv4 addresses, CIDR ranges are only added if explicitly stored in database // (e.g., "45.154.99.28/28" format). No auto-generation to avoid false matches. } } } } $this->serverIpCidrs = array_values(array_unique($cidrs)); $this->cachedServerIps = array_values(array_unique($exactIps)); Log::debug('Core Router getServerIps - extracted IPs', [ 'raw_values' => $rawValues->all(), 'exact_ips' => $this->cachedServerIps, 'cidrs' => $this->serverIpCidrs, 'primary_ipv4' => $primaryIpv4Value, 'primary_ipv6' => $primaryIpv6Value, ]); return $this->cachedServerIps; } /** * Check if a connection's source or destination matches server's assigned IPs * Used for visual filtering in the blade template */ public function isConnectionRelevant(string $source, string $destination): bool { $serverIps = $this->getServerIps(); if (empty($serverIps)) { return false; } // Normalize for comparison $sourceNormalized = strtolower(trim($source)); $destinationNormalized = strtolower(trim($destination)); // Check exact matches foreach ($serverIps as $serverIp) { $serverIpNormalized = strtolower(trim($serverIp)); if ($sourceNormalized === $serverIpNormalized || $destinationNormalized === $serverIpNormalized) { return true; } } return false; } protected function formatConnections(array $states, array $ips): array { // Legacy method - redirect to simple version return $this->formatConnectionsSimple($states); } protected function aggregateTopConnections(array $connections): array { if (empty($connections)) { return []; } $grouped = []; foreach ($connections as $connection) { $key = $connection['direction'] === 'Outbound' ? $connection['destination'] . ':' . ($connection['destination_port'] ?? '0') : $connection['source'] . ':' . ($connection['source_port'] ?? '0'); if (!isset($grouped[$key])) { $grouped[$key] = [ 'endpoint' => $connection['direction'] === 'Outbound' ? $connection['destination'] : $connection['source'], 'port' => $connection['direction'] === 'Outbound' ? $connection['destination_port'] : $connection['source_port'], 'bytes' => 0, 'packets' => 0, 'count' => 0, ]; } $grouped[$key]['bytes'] += $connection['bytes']; $grouped[$key]['packets'] += $connection['packets']; $grouped[$key]['count']++; } $results = array_map(function ($item) { $item['bytes_human'] = $this->formatBytes($item['bytes']); return $item; }, array_values($grouped)); usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']); return $results; } protected function aggregateTopPorts(array $connections): array { if (empty($connections)) { return []; } $grouped = []; foreach ($connections as $connection) { $port = $connection['destination_port'] ?? $connection['source_port'] ?? null; if (!$port) { continue; } if (!isset($grouped[$port])) { $grouped[$port] = [ 'port' => $port, 'bytes' => 0, 'packets' => 0, 'connections' => 0, ]; } $grouped[$port]['bytes'] += $connection['bytes']; $grouped[$port]['packets'] += $connection['packets']; $grouped[$port]['connections']++; } $results = array_map(function ($item) { $item['bytes_human'] = $this->formatBytes($item['bytes']); return $item; }, array_values($grouped)); usort($results, fn ($a, $b) => $b['bytes'] <=> $a['bytes']); return $results; } /** * Get gateways from server's network configuration (network tab) */ protected function getServerGateways(): array { $this->server->loadMissing(['ipAddresses', 'primaryIpv4', 'primaryIpv6']); $gateways = []; // Get gateways from IP addresses foreach ($this->server->ipAddresses as $ipAddress) { if (!empty($ipAddress->gateway)) { $gatewayIp = trim($ipAddress->gateway); // Avoid duplicates if (!isset($gateways[$gatewayIp])) { $gateways[$gatewayIp] = [ 'ip' => $gatewayIp, 'name' => $gatewayIp, 'type' => $ipAddress->type ?? 'ipv4', // ipv4 or ipv6 'associated_ip' => $ipAddress->ip, ]; } } } return array_values($gateways); } /** * Check connections against known malicious IP blocklists */ protected function checkSecurityThreats(array $connections): array { $warnings = []; if (empty($connections)) { Log::debug('Security check: No connections to check'); return []; } // Fetch and parse blocklists $ipv6Blocklist = $this->getIpv6Blocklist(); $ipv4Blocklist = $this->getIpv4Blocklist(); Log::debug('Security check: Blocklists loaded', [ 'ipv6_count' => count($ipv6Blocklist), 'ipv4_count' => count($ipv4Blocklist), 'connections_count' => count($connections), ]); foreach ($connections as $connection) { $source = $connection['source'] ?? ''; $destination = $connection['destination'] ?? ''; // Check source IP $sourceThreat = $this->checkIpAgainstBlocklist($source, $ipv4Blocklist, $ipv6Blocklist); if ($sourceThreat) { // For source threats (malicious IP attacking us), analyze the destination port (port being targeted) // Always use destination_port as that's the service port being targeted, not the ephemeral source port $threatPort = $connection['destination_port'] ?? null; // If destination_port is null, try to extract from raw destination field if ($threatPort === null && isset($connection['raw_destination'])) { $threatPort = $this->extractPortFromField($connection['raw_destination']); } // NEVER use source_port for attack analysis - it's just an ephemeral port // Log for debugging Log::debug('Security threat detected: Source IP - Port Analysis', [ 'ip' => $source, 'threat' => $sourceThreat, 'destination_port' => $connection['destination_port'] ?? 'null', 'source_port' => $connection['source_port'] ?? 'null', 'raw_destination' => $connection['raw_destination'] ?? 'null', 'extracted_threat_port' => $threatPort, ]); $riskAnalysis = $this->analyzeThreatRisk($source, $threatPort, $connection); $warnings[] = [ 'ip' => $source, 'type' => 'source', 'threat' => $sourceThreat, 'connection' => $connection, 'risk_level' => $riskAnalysis['risk_level'], 'attack_type' => $riskAnalysis['attack_type'], 'risk_description' => $riskAnalysis['risk_description'], 'mitigations' => $riskAnalysis['mitigations'] ?? [], ]; Log::debug('Security threat detected: Source IP', [ 'ip' => $source, 'threat' => $sourceThreat, 'risk_level' => $riskAnalysis['risk_level'], 'attack_type' => $riskAnalysis['attack_type'], 'threat_port' => $threatPort, ]); } // Check destination IP $destThreat = $this->checkIpAgainstBlocklist($destination, $ipv4Blocklist, $ipv6Blocklist); if ($destThreat) { // For destination threats (we're connecting to malicious IP), analyze the destination port (port on malicious server) // Always use destination_port as that's the service port on the malicious server $threatPort = $connection['destination_port'] ?? null; // If destination_port is null, try to extract from raw destination field if ($threatPort === null && isset($connection['raw_destination'])) { $threatPort = $this->extractPortFromField($connection['raw_destination']); } // NEVER use source_port for attack analysis - it's just an ephemeral port $riskAnalysis = $this->analyzeThreatRisk($destination, $threatPort, $connection); $warnings[] = [ 'ip' => $destination, 'type' => 'destination', 'threat' => $destThreat, 'connection' => $connection, 'risk_level' => $riskAnalysis['risk_level'], 'attack_type' => $riskAnalysis['attack_type'], 'risk_description' => $riskAnalysis['risk_description'], 'mitigations' => $riskAnalysis['mitigations'] ?? [], ]; Log::debug('Security threat detected: Destination IP', [ 'ip' => $destination, 'threat' => $destThreat, 'risk_level' => $riskAnalysis['risk_level'], 'attack_type' => $riskAnalysis['attack_type'], ]); } } Log::debug('Security check completed', [ 'warnings_count' => count($warnings), ]); // Group similar threats together $groupedWarnings = $this->groupSimilarThreats($warnings); Log::debug('Security threats grouped', [ 'original_count' => count($warnings), 'grouped_count' => count($groupedWarnings), ]); return $groupedWarnings; } /** * Group similar security threats by source, destination, ports, and protocol */ protected function groupSimilarThreats(array $warnings): array { if (empty($warnings)) { return []; } $grouped = []; foreach ($warnings as $warning) { $connection = $warning['connection'] ?? []; $source = $connection['source'] ?? ''; $destination = $connection['destination'] ?? ''; $sourcePort = $connection['source_port'] ?? null; $destinationPort = $connection['destination_port'] ?? null; $protocol = $connection['protocol'] ?? 'N/A'; $threatType = $warning['type'] ?? 'source'; $threatIp = $warning['ip'] ?? ''; // Create a unique key based on source IP, destination IP, destination port, protocol, and threat type // Exclude source port from grouping since it's often ephemeral and varies between connections $key = sprintf( '%s|%s|%s|%s|%s|%s', $threatType, $threatIp, $source, $destination, $destinationPort ?? 'null', $protocol ); if (!isset($grouped[$key])) { // First occurrence - use this warning as the base $grouped[$key] = $warning; $grouped[$key]['quantity'] = 1; $grouped[$key]['is_repeated'] = false; } else { // Duplicate found - increment quantity and mark as repeated $grouped[$key]['quantity'] = ($grouped[$key]['quantity'] ?? 1) + 1; $grouped[$key]['is_repeated'] = true; // Aggregate connection data (sum bytes and packets) $existingConn = $grouped[$key]['connection'] ?? []; $newConn = $connection; $grouped[$key]['connection'] = [ 'source' => $existingConn['source'] ?? $source, 'destination' => $existingConn['destination'] ?? $destination, 'source_port' => $existingConn['source_port'] ?? $sourcePort, 'destination_port' => $existingConn['destination_port'] ?? $destinationPort, 'protocol' => $existingConn['protocol'] ?? $protocol, 'bytes' => ($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0), 'packets' => ($existingConn['packets'] ?? 0) + ($newConn['packets'] ?? 0), 'bytes_human' => $this->formatBytes(($existingConn['bytes'] ?? 0) + ($newConn['bytes'] ?? 0)), 'state' => $existingConn['state'] ?? $newConn['state'] ?? 'N/A', 'direction' => $existingConn['direction'] ?? $newConn['direction'] ?? 'N/A', 'raw_source' => $existingConn['raw_source'] ?? $newConn['raw_source'] ?? '', 'raw_destination' => $existingConn['raw_destination'] ?? $newConn['raw_destination'] ?? '', ]; } } // Convert back to indexed array and sort by quantity (descending), then by risk level $result = array_values($grouped); usort($result, function ($a, $b) { $quantityA = $a['quantity'] ?? 1; $quantityB = $b['quantity'] ?? 1; // First sort by quantity (descending) if ($quantityA !== $quantityB) { return $quantityB <=> $quantityA; } // Then by risk level severity $severityA = $this->getRiskSeverity($a['risk_level'] ?? 'Medium'); $severityB = $this->getRiskSeverity($b['risk_level'] ?? 'Medium'); return $severityB <=> $severityA; }); return $result; } /** * Check if an IP matches any entry in the blocklists */ protected function checkIpAgainstBlocklist(string $ip, array $ipv4Blocklist, array $ipv6Blocklist): ?string { if (empty($ip)) { return null; } $normalizedIp = $this->normalizeSingleIp($ip); if ($normalizedIp === null) { Log::debug('Security check: Failed to normalize IP', [ 'ip' => $ip, ]); return null; } $isIPv6 = str_contains($normalizedIp, ':'); if ($isIPv6) { // Check IPv6 blocklist (CIDR ranges) if (empty($ipv6Blocklist)) { Log::debug('Security check: IPv6 blocklist is empty', [ 'ip' => $normalizedIp, ]); return null; } foreach ($ipv6Blocklist as $cidr) { if ($this->ipInCidr($normalizedIp, $cidr)) { Log::debug('Security check: IPv6 match found', [ 'ip' => $normalizedIp, 'cidr' => $cidr, ]); return "IPv6 address matches Spamhaus DROPv6 blocklist: {$cidr}"; } } } else { // Check IPv4 blocklist (exact IPs) if (empty($ipv4Blocklist)) { Log::debug('Security check: IPv4 blocklist is empty', [ 'ip' => $normalizedIp, ]); return null; } if (in_array($normalizedIp, $ipv4Blocklist, true)) { Log::debug('Security check: IPv4 match found', [ 'ip' => $normalizedIp, ]); return "IPv4 address matches blocklist.de malicious IP list"; } } return null; } /** * Analyze threat risk level and attack type based on port and IP */ protected function analyzeThreatRisk(string $ip, ?int $port, array $connection): array { $riskLevel = 'Medium'; $attackType = 'Unknown Threat'; $riskDescription = 'Malicious IP detected from blocklist'; // Port-based analysis if ($port !== null) { $portAnalysis = $this->analyzePortRisk($port, $connection); $riskLevel = $portAnalysis['risk_level']; $attackType = $portAnalysis['attack_type']; $riskDescription = $portAnalysis['description']; } // IP-based risk adjustment $ipRisk = $this->analyzeIpRisk($ip); if ($ipRisk['severity'] > $this->getRiskSeverity($riskLevel)) { $riskLevel = $ipRisk['risk_level']; if ($attackType === 'Unknown Threat') { $attackType = $ipRisk['attack_type']; } } // Direction-based risk adjustment $direction = $connection['direction'] ?? ''; if ($direction === 'Inbound' && $riskLevel !== 'Critical') { // Inbound threats are generally more dangerous if ($riskLevel === 'Low') { $riskLevel = 'Medium'; } elseif ($riskLevel === 'Medium') { $riskLevel = 'High'; } } // Get mitigation suggestions based on attack type and port $mitigations = $this->getMitigationSuggestions($attackType, $port, $connection); return [ 'risk_level' => $riskLevel, 'attack_type' => $attackType, 'risk_description' => $riskDescription, 'mitigations' => $mitigations, ]; } /** * Get mitigation suggestions based on attack type and port */ protected function getMitigationSuggestions(string $attackType, ?int $port, array $connection): array { $mitigations = []; $direction = $connection['direction'] ?? ''; $isInbound = $direction === 'Inbound'; // Don't provide mitigations for outbound threats (we're connecting to malicious IPs) if (!$isInbound) { return [ [ 'priority' => 'High', 'title' => 'Review Outbound Connections', 'description' => 'This server is initiating connections to a malicious IP. Review the application or service making these connections and ensure it is legitimate and up-to-date.', 'action' => 'Review application logs and system processes to identify why connections are being made to this malicious IP address.', ] ]; } // SSH Brute Force (port 22) if ($attackType === 'SSH Brute Force' || $port === 22) { $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Implement fail2ban', 'description' => 'Automatically ban IPs that fail SSH authentication multiple times. This is highly effective against brute force attacks.', 'action' => "Install: sudo apt install fail2ban (Ubuntu/Debian) or sudo yum install fail2ban (RHEL/CentOS). Configure /etc/fail2ban/jail.local to enable sshd protection.", ], [ 'priority' => 'Critical', 'title' => 'Disable password authentication, use SSH keys only', 'description' => 'Eliminate the attack vector by removing password-based authentication entirely.', 'action' => 'Edit /etc/ssh/sshd_config: Set PasswordAuthentication no, PubkeyAuthentication yes. Restart SSH: sudo systemctl restart sshd', ], [ 'priority' => 'High', 'title' => 'Change SSH port', 'description' => 'Reduce automated attack attempts by moving SSH to a non-standard port.', 'action' => 'Edit /etc/ssh/sshd_config: Change Port 22 to Port [your chosen port]. Update firewall rules. Restart SSH: sudo systemctl restart sshd', ], [ 'priority' => 'High', 'title' => 'Disable root login', 'description' => 'Prevent direct root login attempts via SSH.', 'action' => 'Edit /etc/ssh/sshd_config: Set PermitRootLogin no. Restart SSH: sudo systemctl restart sshd', ], [ 'priority' => 'Medium', 'title' => 'Configure iptables/UFW rate limiting', 'description' => 'Limit connection attempts per IP address to prevent brute force attacks.', 'action' => 'UFW: sudo ufw limit ssh/tcp. iptables: sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set --name ssh && sudo iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --update --seconds 60 --hitcount 4 --name ssh -j DROP', ], [ 'priority' => 'Medium', 'title' => 'Use TCP Wrappers', 'description' => 'Additional access control layer for SSH connections.', 'action' => 'Edit /etc/hosts.allow and /etc/hosts.deny to restrict SSH access by IP address or hostname.', ], [ 'priority' => 'Medium', 'title' => 'Implement two-factor authentication (2FA)', 'description' => 'Add an extra layer of security even if credentials are compromised.', 'action' => 'Use Google Authenticator or similar: Install libpam-google-authenticator, configure PAM for SSH.', ], ]; } // RDP Attack (port 3389) if ($attackType === 'RDP Attack' || $port === 3389) { $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Disable RDP if not needed', 'description' => 'RDP is a high-value target for attackers. If not required, disable it completely.', 'action' => 'Windows: Disable Remote Desktop in System Settings > Remote Desktop. Linux with xrdp: sudo systemctl disable xrdp && sudo systemctl stop xrdp', ], [ 'priority' => 'Critical', 'title' => 'Use VPN instead of exposing RDP', 'description' => 'Never expose RDP directly to the internet. Route connections through a VPN.', 'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to RDP through VPN tunnel only.', ], [ 'priority' => 'High', 'title' => 'Implement fail2ban for RDP', 'description' => 'Automatically ban IPs that fail RDP authentication multiple times.', 'action' => 'Install fail2ban and configure a custom jail for RDP/xrdp. Monitor authentication logs and ban repeated failures.', ], [ 'priority' => 'High', 'title' => 'Change RDP port', 'description' => 'Reduce automated attack attempts by moving RDP to a non-standard port.', 'action' => 'Windows: Edit registry key HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp\\PortNumber. Linux xrdp: Edit /etc/xrdp/xrdp.ini, change port=3389', ], [ 'priority' => 'High', 'title' => 'Enable Network Level Authentication (NLA)', 'description' => 'Require authentication before establishing RDP session.', 'action' => 'Windows: Enable NLA in Remote Desktop settings. This adds an extra authentication layer before the session is established.', ], [ 'priority' => 'Medium', 'title' => 'Configure firewall rules', 'description' => 'Restrict RDP access to specific trusted IP addresses only.', 'action' => 'UFW: sudo ufw allow from [trusted-ip] to any port 3389. iptables: sudo iptables -A INPUT -p tcp -s [trusted-ip] --dport 3389 -j ACCEPT && sudo iptables -A INPUT -p tcp --dport 3389 -j DROP', ], ]; } // Database attacks (MySQL, PostgreSQL, SQL Server) if (in_array($attackType, ['Database Attack', 'SQL Injection']) || in_array($port, [1433, 3306, 5432])) { $dbType = $port === 1433 ? 'SQL Server' : ($port === 3306 ? 'MySQL' : ($port === 5432 ? 'PostgreSQL' : 'Database')); $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Block database port at firewall level', 'description' => "Never expose {$dbType} directly to the internet. Databases should only be accessible from application servers.", 'action' => "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP", ], [ 'priority' => 'Critical', 'title' => 'Use SSH tunnel or VPN for database access', 'description' => 'If remote database access is needed, route it through an encrypted tunnel.', 'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:[db-port] user@server. Connect database client to localhost:[local-port]', ], [ 'priority' => 'High', 'title' => 'Bind database to localhost only', 'description' => "Configure {$dbType} to only listen on localhost interface, not on public interfaces.", 'action' => "MySQL: Edit my.cnf, set bind-address = 127.0.0.1. PostgreSQL: Edit postgresql.conf, set listen_addresses = 'localhost'. Restart database service.", ], [ 'priority' => 'High', 'title' => 'Use strong passwords and limit user privileges', 'description' => 'Implement least-privilege access and strong authentication.', 'action' => "Change default passwords. Create database users with minimal required privileges. Use {$dbType} user management to restrict access by IP.", ], [ 'priority' => 'High', 'title' => 'Implement fail2ban for database', 'description' => 'Automatically ban IPs that fail database authentication multiple times.', 'action' => 'Configure fail2ban to monitor database authentication logs. Create custom jail for repeated authentication failures.', ], [ 'priority' => 'Medium', 'title' => 'Enable SSL/TLS for database connections', 'description' => 'Encrypt database connections to prevent credential theft.', 'action' => "Configure SSL certificates for {$dbType}. Update connection strings to require SSL. Update firewall rules accordingly.", ], ]; } // SMB/NetBIOS attacks if (in_array($attackType, ['SMB Exploit', 'NetBIOS Attack']) || in_array($port, [445, 139])) { $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Disable SMB if not needed', 'description' => 'SMB is a common attack vector for ransomware and lateral movement. Disable if not required.', 'action' => 'Linux: sudo systemctl disable smbd nmbd && sudo systemctl stop smbd nmbd. Windows: Disable SMB in Windows Features.', ], [ 'priority' => 'High', 'title' => 'Block SMB ports at firewall', 'description' => 'Prevent SMB access from the internet. It should only be accessible on local network.', 'action' => 'UFW: sudo ufw deny 445/tcp && sudo ufw deny 139/tcp. iptables: Block ports 445 and 139 from external interfaces.', ], [ 'priority' => 'High', 'title' => 'Use VPN for SMB access', 'description' => 'If SMB access is required remotely, route it through a VPN tunnel.', 'action' => 'Set up OpenVPN or WireGuard VPN. Only allow SMB access through VPN tunnel. Configure firewall accordingly.', ], [ 'priority' => 'High', 'title' => 'Update Samba/Windows to latest version', 'description' => 'Older SMB versions have known vulnerabilities. Keep software updated.', 'action' => 'Update Samba: sudo apt update && sudo apt upgrade samba (Ubuntu/Debian). Windows: Install latest security updates.', ], [ 'priority' => 'Medium', 'title' => 'Disable SMBv1 protocol', 'description' => 'SMBv1 is deprecated and vulnerable. Use SMBv2 or SMBv3 only.', 'action' => 'Linux: Edit smb.conf, add min protocol = SMB2. Windows: Disable SMBv1 in Windows Features or via PowerShell: Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol', ], [ 'priority' => 'Medium', 'title' => 'Restrict SMB access by IP', 'description' => 'Limit SMB access to specific trusted IP addresses only.', 'action' => 'Configure firewall rules to only allow SMB connections from trusted IPs. Use hosts.allow for additional access control.', ], ]; } // Web attacks (HTTP/HTTPS) if ($attackType === 'Web Attack' || in_array($port, [80, 443, 8080, 8443])) { $mitigations = [ [ 'priority' => 'High', 'title' => 'Implement Web Application Firewall (WAF)', 'description' => 'Protect against web application attacks, SQL injection, XSS, and other web-based exploits.', 'action' => 'Use ModSecurity with Apache/Nginx, Cloudflare WAF, or AWS WAF. Configure rules to block common attack patterns.', ], [ 'priority' => 'High', 'title' => 'Configure rate limiting', 'description' => 'Limit requests per IP to prevent DDoS and brute force attacks.', 'action' => 'Nginx: limit_req_zone and limit_req directives. Apache: mod_evasive or mod_ratelimit. Also configure at firewall level with iptables/UFW.', ], [ 'priority' => 'High', 'title' => 'Keep web server and applications updated', 'description' => 'Outdated software has known vulnerabilities that attackers exploit.', 'action' => 'Regularly update web server (Apache/Nginx), PHP, and all web applications. Subscribe to security advisories.', ], [ 'priority' => 'Medium', 'title' => 'Implement fail2ban for web services', 'description' => 'Automatically ban IPs showing malicious web request patterns.', 'action' => 'Configure fail2ban to monitor web server logs. Create custom filters for repeated 404 errors, SQL injection attempts, etc.', ], [ 'priority' => 'Medium', 'title' => 'Use HTTPS only (port 443)', 'description' => 'Encrypt web traffic to prevent man-in-the-middle attacks and credential theft.', 'action' => 'Install SSL certificate (Let\'s Encrypt). Configure web server to redirect HTTP (80) to HTTPS (443). Enable HSTS headers.', ], [ 'priority' => 'Medium', 'title' => 'Configure firewall to block unnecessary ports', 'description' => 'If using standard HTTPS (443), block alternative ports like 8080 and 8443 unless needed.', 'action' => 'UFW: sudo ufw deny 8080/tcp && sudo ufw deny 8443/tcp (if not needed). Only allow required web ports.', ], [ 'priority' => 'Low', 'title' => 'Hide server version information', 'description' => 'Reduce information disclosure that helps attackers identify vulnerabilities.', 'action' => 'Configure web server to hide version banners. Disable server signature: ServerTokens Prod (Apache), server_tokens off (Nginx).', ], ]; } // Email attacks if (str_contains($attackType, 'Email') || in_array($port, [25, 110, 143, 993, 995])) { $mitigations = [ [ 'priority' => 'High', 'title' => 'Configure SPF, DKIM, and DMARC records', 'description' => 'Prevent email spoofing and improve email authentication.', 'action' => 'Add SPF, DKIM, and DMARC DNS records for your domain. Configure mail server to sign outgoing emails.', ], [ 'priority' => 'High', 'title' => 'Require strong authentication', 'description' => 'Use strong passwords and consider implementing two-factor authentication for email.', 'action' => 'Enforce password complexity policies. Enable 2FA if supported. Use OAuth2 or modern authentication methods.', ], [ 'priority' => 'High', 'title' => 'Block port 25 (SMTP) from external access', 'description' => 'Prevent unauthorized email relaying and spam attacks.', 'action' => 'Configure mail server to only accept SMTP connections from trusted IPs. Use UFW/iptables to restrict port 25 access.', ], [ 'priority' => 'Medium', 'title' => 'Implement fail2ban for mail services', 'description' => 'Automatically ban IPs that fail email authentication multiple times.', 'action' => 'Configure fail2ban to monitor mail server authentication logs. Create jails for POP3, IMAP, and SMTP authentication failures.', ], [ 'priority' => 'Medium', 'title' => 'Use encrypted email protocols', 'description' => 'Prefer secure protocols (IMAPS, POP3S) over unencrypted ones.', 'action' => 'Disable unencrypted POP3 (110) and IMAP (143). Require SSL/TLS for all email connections. Update firewall rules.', ], [ 'priority' => 'Low', 'title' => 'Change default email ports', 'description' => 'Reduce automated attack attempts by using non-standard ports.', 'action' => 'Consider changing to non-standard ports for email services if your email clients support it. However, this reduces compatibility.', ], ]; } // FTP attacks if ($attackType === 'FTP Attack' || $port === 21) { $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Disable FTP, use SFTP instead', 'description' => 'FTP is unencrypted and insecure. SFTP (over SSH) is the secure alternative.', 'action' => 'Disable FTP server: sudo systemctl disable vsftpd (or proftpd). Use SSH/SFTP instead, which uses port 22 and is encrypted.', ], [ 'priority' => 'High', 'title' => 'If FTP is required, use FTPS only', 'description' => 'Use FTP over SSL/TLS to encrypt credentials and data.', 'action' => 'Configure FTP server to require TLS/SSL. Disable plain FTP. Block port 21 for plain FTP connections.', ], [ 'priority' => 'High', 'title' => 'Restrict FTP access by IP', 'description' => 'Limit FTP access to specific trusted IP addresses only.', 'action' => 'Configure firewall to only allow FTP from trusted IPs. Use FTP server configuration to restrict access by IP address.', ], [ 'priority' => 'Medium', 'title' => 'Implement fail2ban for FTP', 'description' => 'Automatically ban IPs that fail FTP authentication multiple times.', 'action' => 'Configure fail2ban to monitor FTP authentication logs. Create custom jail for repeated authentication failures.', ], ]; } // Telnet attacks if ($attackType === 'Telnet Exploit' || $port === 23) { $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Disable Telnet completely', 'description' => 'Telnet is completely unencrypted and should never be used. Use SSH instead.', 'action' => 'Disable Telnet: sudo systemctl disable telnet && sudo systemctl stop telnet. Remove telnet server package. Block port 23 at firewall.', ], [ 'priority' => 'Critical', 'title' => 'Use SSH instead', 'description' => 'SSH provides encrypted remote access, replacing the insecure Telnet protocol.', 'action' => 'Use SSH (port 22) for all remote terminal access. SSH encrypts all data and credentials.', ], ]; } // VNC attacks if ($attackType === 'VNC Attack' || $port === 5900) { $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Never expose VNC directly to internet', 'description' => 'VNC is vulnerable to brute force attacks. Always route through VPN or SSH tunnel.', 'action' => 'Block VNC port at firewall. Use SSH tunnel: ssh -L 5900:localhost:5900 user@server. Connect VNC client to localhost:5900', ], [ 'priority' => 'High', 'title' => 'Use SSH tunnel for VNC access', 'description' => 'Encrypt VNC connections by tunneling through SSH.', 'action' => 'Create SSH tunnel: ssh -L [local-port]:localhost:5900 user@server. Connect VNC to localhost:[local-port]. Configure firewall to block port 5900.', ], [ 'priority' => 'High', 'title' => 'Enable VNC password and strong authentication', 'description' => 'Use strong passwords if VNC must be used without tunneling.', 'action' => 'Set strong VNC password: vncpasswd. Consider using VNC over SSL if available. Still prefer SSH tunneling.', ], [ 'priority' => 'Medium', 'title' => 'Change VNC port', 'description' => 'Reduce automated attack attempts by using non-standard port.', 'action' => 'Configure VNC to use non-standard port. Update firewall rules. Still route through SSH tunnel for security.', ], ]; } // DNS attacks if ($attackType === 'DNS Attack' || $port === 53) { $mitigations = [ [ 'priority' => 'High', 'title' => 'Restrict DNS to local network only', 'description' => 'DNS servers should typically only serve local networks, not be exposed to the internet.', 'action' => 'Configure DNS server to only listen on local network interface. Block external DNS queries at firewall unless you operate a public DNS server.', ], [ 'priority' => 'High', 'title' => 'Use DNS over HTTPS (DoH) or DNS over TLS (DoT)', 'description' => 'Encrypt DNS queries to prevent interception and manipulation.', 'action' => 'Configure DNS clients to use DoH/DoT. Update applications to use encrypted DNS protocols.', ], [ 'priority' => 'Medium', 'title' => 'Configure rate limiting for DNS', 'description' => 'Prevent DNS amplification attacks and excessive queries.', 'action' => 'Configure DNS server (BIND, unbound) with rate limiting. Limit queries per IP address.', ], [ 'priority' => 'Medium', 'title' => 'Keep DNS software updated', 'description' => 'Outdated DNS software has known vulnerabilities.', 'action' => 'Regularly update DNS server software. Subscribe to security advisories for your DNS server (BIND, unbound, etc.).', ], ]; } // Windows RPC attacks if ($attackType === 'Windows Exploit' || $port === 135) { $mitigations = [ [ 'priority' => 'Critical', 'title' => 'Block Windows RPC ports at firewall', 'description' => 'Windows RPC should never be exposed to the internet. It is used for service enumeration and attacks.', 'action' => 'Block ports 135, 137, 138, 139, 445 at firewall. Windows: Configure Windows Firewall. Linux firewall: Use UFW/iptables to block these ports.', ], [ 'priority' => 'High', 'title' => 'Use VPN for Windows RPC access', 'description' => 'If Windows RPC access is needed remotely, route it through a VPN tunnel.', 'action' => 'Set up VPN server. Only allow Windows RPC access through VPN tunnel. Configure firewall to block direct RPC access.', ], [ 'priority' => 'High', 'title' => 'Keep Windows updated', 'description' => 'Windows RPC vulnerabilities are regularly patched. Keep system updated.', 'action' => 'Install latest Windows security updates. Enable automatic updates for critical security patches.', ], ]; } // Generic/unknown attacks or port scans if (empty($mitigations)) { $mitigations = [ [ 'priority' => 'High', 'title' => 'Block port at firewall', 'description' => "If this port ({$port}) is not needed, block it completely at the firewall level.", 'action' => $port ? "UFW: sudo ufw deny {$port}/tcp. iptables: sudo iptables -A INPUT -p tcp --dport {$port} -j DROP" : 'Configure firewall to block this port.', ], [ 'priority' => 'High', 'title' => 'Implement fail2ban', 'description' => 'Automatically ban IPs showing malicious connection patterns.', 'action' => 'Install fail2ban. Create custom filter to monitor logs for this service. Configure jail to ban repeated connection attempts.', ], [ 'priority' => 'Medium', 'title' => 'Review service necessity', 'description' => "Evaluate if the service on port {$port} is necessary. Disable unused services to reduce attack surface.", 'action' => 'Review running services: sudo netstat -tulpn or sudo ss -tulpn. Disable services that are not required.', ], [ 'priority' => 'Medium', 'title' => 'Restrict access by IP address', 'description' => 'Limit access to specific trusted IP addresses only.', 'action' => $port ? "UFW: sudo ufw allow from [trusted-ip] to any port {$port}. iptables: Allow only from trusted IPs." : 'Configure firewall to restrict access by IP.', ], [ 'priority' => 'Low', 'title' => 'Use VPN for remote access', 'description' => 'If remote access is needed, route connections through a VPN instead of exposing services directly.', 'action' => 'Set up OpenVPN or WireGuard VPN server. Configure firewall to only allow VPN access. Connect to services through VPN tunnel.', ], ]; } // Add general mitigations for all attacks $generalMitigations = [ [ 'priority' => 'Medium', 'title' => 'Implement IP blocklist at firewall', 'description' => 'Automatically block known malicious IP addresses at the firewall level.', 'action' => 'Use ipset with iptables to maintain blocklists. Integrate with threat intelligence feeds. Automatically update blocklists.', ], [ 'priority' => 'Low', 'title' => 'Monitor and log all connection attempts', 'description' => 'Maintain comprehensive logs for security analysis and incident response.', 'action' => 'Configure syslog to centralize logs. Use log analysis tools (ELK stack, Splunk). Set up alerts for suspicious activity patterns.', ], ]; // Merge general mitigations $mitigations = array_merge($mitigations, $generalMitigations); // Sort by priority (Critical > High > Medium > Low) $priorityOrder = ['Critical' => 4, 'High' => 3, 'Medium' => 2, 'Low' => 1]; usort($mitigations, function ($a, $b) use ($priorityOrder) { $priA = $priorityOrder[$a['priority']] ?? 0; $priB = $priorityOrder[$b['priority']] ?? 0; return $priB <=> $priA; }); return $mitigations; } /** * Analyze risk based on port number */ protected function analyzePortRisk(int $port, array $connection): array { // Critical risk ports (commonly exploited) $criticalPorts = [ 22 => ['attack_type' => 'SSH Brute Force', 'description' => 'SSH (port 22) is a common target for brute force attacks attempting to gain unauthorized access'], 23 => ['attack_type' => 'Telnet Exploit', 'description' => 'Telnet (port 23) is unencrypted and highly vulnerable to credential theft and man-in-the-middle attacks'], 3389 => ['attack_type' => 'RDP Attack', 'description' => 'RDP (port 3389) is frequently targeted for remote desktop brute force and ransomware attacks'], 445 => ['attack_type' => 'SMB Exploit', 'description' => 'SMB (port 445) is commonly exploited for ransomware, lateral movement, and data exfiltration'], 1433 => ['attack_type' => 'SQL Injection', 'description' => 'SQL Server (port 1433) is targeted for SQL injection attacks and database compromise'], 3306 => ['attack_type' => 'Database Attack', 'description' => 'MySQL (port 3306) is targeted for database brute force and SQL injection attacks'], 5432 => ['attack_type' => 'Database Attack', 'description' => 'PostgreSQL (port 5432) is targeted for database exploitation and unauthorized access'], 135 => ['attack_type' => 'Windows Exploit', 'description' => 'Windows RPC (port 135) is used for service enumeration and remote procedure call attacks'], 139 => ['attack_type' => 'NetBIOS Attack', 'description' => 'NetBIOS (port 139) is exploited for network enumeration and SMB protocol attacks'], ]; // High risk ports $highRiskPorts = [ 21 => ['attack_type' => 'FTP Attack', 'description' => 'FTP (port 21) is vulnerable to credential theft and unencrypted data transmission'], 25 => ['attack_type' => 'Email Spam', 'description' => 'SMTP (port 25) is commonly used for spam, phishing, and email-based attacks'], 53 => ['attack_type' => 'DNS Attack', 'description' => 'DNS (port 53) can be exploited for DNS amplification, cache poisoning, or data exfiltration'], 110 => ['attack_type' => 'Email Attack', 'description' => 'POP3 (port 110) is vulnerable to credential theft and email interception'], 143 => ['attack_type' => 'Email Attack', 'description' => 'IMAP (port 143) is vulnerable to credential theft and email interception'], 993 => ['attack_type' => 'Email Attack', 'description' => 'IMAPS (port 993) can be targeted for email account compromise'], 995 => ['attack_type' => 'Email Attack', 'description' => 'POP3S (port 995) can be targeted for email account compromise'], 8080 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTP (port 8080) is often targeted for web application attacks and exploits'], 8443 => ['attack_type' => 'Web Attack', 'description' => 'Alternative HTTPS (port 8443) can be targeted for web application vulnerabilities'], 5900 => ['attack_type' => 'VNC Attack', 'description' => 'VNC (port 5900) is vulnerable to brute force attacks and unauthorized remote access'], ]; // Medium risk ports $mediumRiskPorts = [ 80 => ['attack_type' => 'Web Attack', 'description' => 'HTTP (port 80) can be targeted for web application vulnerabilities and DDoS attacks'], 443 => ['attack_type' => 'Web Attack', 'description' => 'HTTPS (port 443) can be targeted for SSL/TLS vulnerabilities and web application attacks'], 161 => ['attack_type' => 'Network Scan', 'description' => 'SNMP (port 161) is used for network monitoring but can be exploited for information gathering'], 514 => ['attack_type' => 'Log Injection', 'description' => 'Syslog (port 514) can be targeted for log injection and denial of service attacks'], 1521 => ['attack_type' => 'Database Attack', 'description' => 'Oracle (port 1521) can be targeted for database exploitation'], 27017 => ['attack_type' => 'Database Attack', 'description' => 'MongoDB (port 27017) can be targeted for database compromise if misconfigured'], ]; // Check critical ports if (isset($criticalPorts[$port])) { return [ 'risk_level' => 'Critical', 'attack_type' => $criticalPorts[$port]['attack_type'], 'description' => $criticalPorts[$port]['description'], ]; } // Check high risk ports if (isset($highRiskPorts[$port])) { return [ 'risk_level' => 'High', 'attack_type' => $highRiskPorts[$port]['attack_type'], 'description' => $highRiskPorts[$port]['description'], ]; } // Check medium risk ports if (isset($mediumRiskPorts[$port])) { return [ 'risk_level' => 'Medium', 'attack_type' => $mediumRiskPorts[$port]['attack_type'], 'description' => $mediumRiskPorts[$port]['description'], ]; } // Low ports (< 1024) are generally more risky if ($port < 1024) { return [ 'risk_level' => 'Medium', 'attack_type' => 'System Service Exploit', 'description' => "Low port number ({$port}) indicates a system service that could be targeted for exploitation", ]; } // High ports (> 49152) are often used for attacks if ($port > 49152) { return [ 'risk_level' => 'Low', 'attack_type' => 'Ephemeral Port Scan', 'description' => "High port number ({$port}) suggests ephemeral port usage, possibly part of a port scan or connection attempt", ]; } // Default for other ports return [ 'risk_level' => 'Low', 'attack_type' => 'Port Scan', 'description' => "Connection attempt targeting port {$port} (destination service port) from a malicious IP address", ]; } /** * Analyze risk based on IP characteristics */ protected function analyzeIpRisk(string $ip): array { // Check if IP is in private range (less risky if internal) if (!$this->isPublicIP($ip)) { return [ 'risk_level' => 'Low', 'attack_type' => 'Internal Threat', 'severity' => 1, 'description' => 'Malicious IP is in private/internal range', ]; } // IPv6 addresses from blocklists are generally high risk if (str_contains($ip, ':')) { return [ 'risk_level' => 'High', 'attack_type' => 'IPv6 Blocklist Match', 'severity' => 4, 'description' => 'IPv6 address matches Spamhaus DROPv6 blocklist (known malicious network)', ]; } // IPv4 addresses from blocklists are high risk return [ 'risk_level' => 'High', 'attack_type' => 'IPv4 Blocklist Match', 'severity' => 4, 'description' => 'IPv4 address matches blocklist.de malicious IP list', ]; } /** * Get numeric severity for risk level comparison */ protected function getRiskSeverity(string $riskLevel): int { return match($riskLevel) { 'Critical' => 5, 'High' => 4, 'Medium' => 3, 'Low' => 2, default => 1, }; } /** * Fetch and cache IPv6 blocklist from Spamhaus DROPv6 */ protected function getIpv6Blocklist(): array { return Cache::remember('spamhaus-dropv6-blocklist', 3600, function () { try { $response = Http::timeout(10)->get('https://www.spamhaus.org/drop/dropv6.txt'); if (!$response->successful()) { Log::warning('Failed to fetch Spamhaus DROPv6 blocklist', [ 'status' => $response->status(), ]); return []; } $lines = explode("\n", $response->body()); $cidrs = []; foreach ($lines as $line) { $line = trim($line); // Skip comments and empty lines if (empty($line) || str_starts_with($line, ';') || str_starts_with($line, '#')) { continue; } // Extract CIDR (everything before ;) $parts = explode(';', $line, 2); $cidr = trim($parts[0]); if (!empty($cidr)) { // Normalize CIDR if needed if (str_contains($cidr, '/')) { [$ip, $prefix] = explode('/', $cidr, 2); $normalizedIp = $this->normalizeSingleIp($ip); if ($normalizedIp !== null) { $normalizedCidr = $this->normalizeCidr($normalizedIp, (int)$prefix); if ($normalizedCidr) { $cidrs[] = $normalizedCidr; } } } } } Log::debug('Fetched Spamhaus DROPv6 blocklist', [ 'cidr_count' => count($cidrs), 'sample_cidrs' => array_slice($cidrs, 0, 5), ]); return array_unique($cidrs); } catch (\Throwable $e) { Log::error('Error fetching Spamhaus DROPv6 blocklist', [ 'error' => $e->getMessage(), ]); return []; } }); } /** * Fetch and cache IPv4 blocklist from blocklist.de */ protected function getIpv4Blocklist(): array { return Cache::remember('blocklist-de-ipv4-blocklist', 3600, function () { try { $response = Http::timeout(10)->get('https://lists.blocklist.de/lists/all.txt'); if (!$response->successful()) { Log::warning('Failed to fetch blocklist.de IPv4 blocklist', [ 'status' => $response->status(), ]); return []; } $lines = explode("\n", $response->body()); $ips = []; foreach ($lines as $line) { $line = trim($line); // Skip empty lines if (empty($line)) { continue; } // Validate and normalize IP $normalizedIp = $this->normalizeSingleIp($line); if ($normalizedIp !== null && !str_contains($normalizedIp, ':')) { // Only IPv4 addresses $ips[] = $normalizedIp; } } Log::debug('Fetched blocklist.de IPv4 blocklist', [ 'ip_count' => count($ips), 'sample_ips' => array_slice($ips, 0, 5), ]); return array_unique($ips); } catch (\Throwable $e) { Log::error('Error fetching blocklist.de IPv4 blocklist', [ 'error' => $e->getMessage(), ]); return []; } }); } protected function extractStateIp(array $state, string $direction): string { // Core Router API returns source/destination in format: "IP[port]" or separate fields $candidates = [ $direction === 'src' ? 'source' : 'destination', "{$direction}_ip", "{$direction}ip", $direction, $direction === 'src' ? 'source_ip' : 'destination_ip', ]; foreach ($candidates as $candidate) { if (!empty($state[$candidate])) { $value = (string) $state[$candidate]; // Parse format: "2406:da12:fc3:5300::11[53443]" or "45.154.99.28[22]" -> extract IP part // Also handle IPv6 format: "2a0b:4e07:8:1::1:7[12844]" if (preg_match('/^([^\[\]]+)(\[.*\])?$/', $value, $matches)) { $ip = trim($matches[1]); // Normalize the extracted IP $normalized = $this->normalizeSingleIp($ip); return $normalized ?: $ip; } // Handle IPv4 in "ip:port" format (ensure IPv6 not affected) $colonPos = strrpos($value, ':'); if ($colonPos !== false) { $ipPart = substr($value, 0, $colonPos); $portPart = substr($value, $colonPos + 1); // Only treat as IPv4 if the portion before colon is a valid IPv4 address if (filter_var($ipPart, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && ctype_digit($portPart)) { $normalized = $this->normalizeSingleIp($ipPart); return $normalized ?: $ipPart; } } // If no bracket format, try to normalize anyway $normalized = $this->normalizeSingleIp($value); return $normalized ?: $value; } } return 'N/A'; } protected function extractStatePort(array $state, string $direction): ?int { // Core Router API returns source/destination in format: "IP[port]" or separate fields $candidates = [ $direction === 'src' ? 'source' : 'destination', "{$direction}_port", "{$direction}port", $direction === 'src' ? 'source_port' : 'destination_port', $direction === 'src' ? 'sport' : 'dport', ]; foreach ($candidates as $candidate) { if (isset($state[$candidate])) { $value = $state[$candidate]; // If it's a string in format "IP[port]", extract port if (is_string($value) && preg_match('/\[(\d+)\]$/', $value, $matches)) { return (int) $matches[1]; } // Otherwise return as integer return (int) $value; } } return null; } /** * Extract port number from a field that may contain IP:port or IP[port] format */ protected function extractPortFromField(string $field): ?int { if (empty($field)) { return null; } $field = trim($field); // Handle IPv4 "IP:port" format (e.g., "45.154.99.22:22") if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $field, $matches)) { return (int)$matches[2]; } // Handle "IP[port]" format (e.g., "2a0b:4e07:8:1::2:10[22]" or "45.154.99.28[22]") if (preg_match('/\[(\d+)\]$/', $field, $matches)) { return (int)$matches[1]; } return null; } protected function expandIpValue(?string $value): array { $results = []; if (!$value) { return $results; } $sanitized = str_replace(["\r", "\n", "\t"], ' ', (string) $value); $parts = preg_split('/[\s,]+/', $sanitized, -1, PREG_SPLIT_NO_EMPTY); foreach ($parts as $part) { $part = trim($part); if ($part === '') { continue; } if (str_contains($part, '/')) { [$ipCandidate, $prefixPart] = explode('/', $part, 2); $normalizedIp = $this->normalizeSingleIp($ipCandidate); $prefixPart = trim($prefixPart); if ($normalizedIp === null || $prefixPart === '' || !ctype_digit($prefixPart)) { continue; } $prefix = (int) $prefixPart; $maxPrefix = str_contains($normalizedIp, ':') ? 128 : 32; if ($prefix < 0 || $prefix > $maxPrefix) { continue; } // Normalize CIDR to network address (important for matching) $networkCidr = $this->normalizeCidr($normalizedIp, $prefix); if ($networkCidr) { // Add the base IP as an exact match (important for exact IP matching) // This ensures IPs stored as "IP/CIDR" can be matched exactly $results[] = [ 'type' => 'ip', 'value' => $normalizedIp, ]; // Also add the CIDR network for CIDR matching $results[] = [ 'type' => 'cidr', 'value' => $networkCidr, ]; } continue; } $normalizedIp = $this->normalizeSingleIp($part); if ($normalizedIp !== null) { $results[] = [ 'type' => 'ip', 'value' => $normalizedIp, ]; } } return $results; } protected function normalizeSingleIp(?string $ip): ?string { if ($ip === null) { return null; } $ip = strtolower(trim($ip)); if ($ip === '') { return null; } $ip = trim($ip, "[]'\""); if (str_contains($ip, '%')) { $ip = strstr($ip, '%', true) ?: $ip; } return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : null; } /** * Normalize CIDR to network address (e.g., 45.154.99.28/28 -> 45.154.99.16/28) */ protected function normalizeCidr(string $ip, int $prefix): ?string { $isIPv6 = str_contains($ip, ':'); $ipBinary = @inet_pton($ip); if ($ipBinary === false) { return null; } $addressLength = strlen($ipBinary) * 8; if ($prefix < 0 || $prefix > $addressLength) { return null; } // Calculate network address by zeroing out host bits $fullBytes = intdiv($prefix, 8); $remainingBits = $prefix % 8; // Zero out host bytes for ($i = $fullBytes; $i < strlen($ipBinary); $i++) { $ipBinary[$i] = "\0"; } // Zero out remaining host bits in the partial byte if ($remainingBits > 0 && $fullBytes < strlen($ipBinary)) { $mask = ~((1 << (8 - $remainingBits)) - 1) & 0xFF; $ipBinary[$fullBytes] = chr(ord($ipBinary[$fullBytes]) & $mask); } $networkIp = @inet_ntop($ipBinary); if ($networkIp === false) { return null; } return $networkIp . '/' . $prefix; } protected function ipMatchesServer(string $ip, array $exactIps): bool { $normalized = $this->normalizeSingleIp($ip); if ($normalized === null) { return false; } if (in_array($normalized, $exactIps, true)) { return true; } foreach ($this->serverIpCidrs as $cidr) { if ($this->ipInCidr($normalized, $cidr)) { return true; } } return false; } protected function ipInCidr(string $ip, string $cidr): bool { if (!str_contains($cidr, '/')) { return $ip === $cidr; } [$network, $prefixLength] = explode('/', $cidr, 2); $prefixLength = (int) $prefixLength; $ipBinary = @inet_pton($ip); $networkBinary = @inet_pton($network); if ($ipBinary === false || $networkBinary === false) { return false; } if (strlen($ipBinary) !== strlen($networkBinary)) { return false; } $maxPrefix = strlen($networkBinary) * 8; if ($prefixLength < 0 || $prefixLength > $maxPrefix) { return false; } $fullBytes = intdiv($prefixLength, 8); $remainingBits = $prefixLength % 8; if ($fullBytes > 0 && substr($ipBinary, 0, $fullBytes) !== substr($networkBinary, 0, $fullBytes)) { return false; } if ($remainingBits === 0) { return true; } $mask = (~(0xff >> $remainingBits)) & 0xff; return (ord($ipBinary[$fullBytes]) & $mask) === (ord($networkBinary[$fullBytes]) & $mask); } public function getGraphPayloads(): array { return $this->graphPayloads; } public function updateChartData(string $chartType): array { $timeRangeProperty = $chartType . 'TimeRange'; $aggregationProperty = $chartType . 'Aggregation'; $timeRange = $this->$timeRangeProperty; $aggregation = $this->$aggregationProperty; $this->fetchGraphData($chartType, $timeRange, $aggregation, true); $fullData = $this->graphPayloads[$chartType] ?? []; // Filter data to only return essential fields for this chart type to reduce response size $filteredData = []; foreach ($fullData as $point) { $filteredPoint = ['time' => $point['time'] ?? 0]; switch ($chartType) { case 'cpu': $filteredPoint['cpu'] = $point['cpu'] ?? 0; break; case 'memory': $filteredPoint['mem'] = $point['mem'] ?? 0; $filteredPoint['maxmem'] = $point['maxmem'] ?? 0; break; case 'network': $filteredPoint['netin'] = $point['netin'] ?? 0; $filteredPoint['netout'] = $point['netout'] ?? 0; break; case 'disk': $filteredPoint['diskread'] = $point['diskread'] ?? 0; $filteredPoint['diskwrite'] = $point['diskwrite'] ?? 0; break; } $filteredData[] = $filteredPoint; } return $filteredData; } public function render() { return view('proxmox::statistics')->layoutData([ 'sidebar' => true, ]); } public function exportChart(string $chartKey) { $rows = $this->exportRowsFor($chartKey); if (empty($rows)) { $this->showExportInfo('No data available to export for this chart. Please refresh the chart and try again.'); return; } $headers = array_keys($rows[0]); $csv = implode(',', $headers) . PHP_EOL; foreach ($rows as $row) { $csv .= implode(',', array_map(function ($value) { return '"' . str_replace('"', '""', (string)$value) . '"'; }, $row)) . PHP_EOL; } $filename = sprintf('%s-chart-%s.csv', strtolower($chartKey), now()->format('Y-m-d_Hi')); return response()->streamDownload(function () use ($csv) { echo $csv; }, $filename, [ 'Content-Type' => 'text/csv', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', ]); } protected function exportRowsFor(string $chartKey): array { $chartKey = strtolower($chartKey); $data = $this->graphPayloads[$chartKey] ?? []; if (empty($data)) { return []; } $rows = []; foreach ($data as $point) { if (!isset($point['time'])) { continue; } $timestamp = Carbon::createFromTimestamp((int)$point['time'])->toDateTimeString(); switch ($chartKey) { case 'cpu': $rows[] = [ 'Timestamp' => $timestamp, 'CPU (%)' => round(($point['cpu'] ?? 0) * 100, 2), ]; break; case 'memory': $rows[] = [ 'Timestamp' => $timestamp, 'Used (GB)' => $this->convertBytes($point['mem'] ?? 0, 'GB'), 'Max (GB)' => $this->convertBytes($point['maxmem'] ?? 0, 'GB'), ]; break; case 'network': $rows[] = [ 'Timestamp' => $timestamp, 'Download (MB/s)' => $this->convertBytes($point['netin'] ?? 0, 'MB'), 'Upload (MB/s)' => $this->convertBytes($point['netout'] ?? 0, 'MB'), ]; break; case 'disk': $rows[] = [ 'Timestamp' => $timestamp, 'Read (MB/s)' => $this->convertBytes($point['diskread'] ?? 0, 'MB'), 'Write (MB/s)' => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'), ]; break; } } return $rows; } protected function makeCpuChart() { $data = $this->graphPayloads['cpu'] ?? []; if (empty($data)) { return null; } $labels = $this->buildLabels($data, $this->cpuTimeRange); $values = array_map(fn ($point) => round(($point['cpu'] ?? 0) * 100, 2), $data); return Chartjs::build() ->name('cpuChart_' . $this->server->id . '_' . $this->cpuTimeRange . '_' . $this->cpuAggregation) ->type('line') ->size(['height' => 280, 'width' => 400]) ->labels($labels) ->datasets([ [ 'label' => 'CPU Usage (%)', 'data' => $values, 'borderColor' => 'rgb(74, 222, 128)', 'backgroundColor' => 'rgba(74, 222, 128, 0.15)', 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, ], ]) ->options($this->chartOptions(100, 'CPU %', false)); } protected function makeMemoryChart() { $data = $this->graphPayloads['memory'] ?? []; if (empty($data)) { return null; } $labels = $this->buildLabels($data, $this->memoryTimeRange); $used = array_map(fn ($point) => $this->convertBytes($point['mem'] ?? 0, 'GB'), $data); $max = array_map(fn ($point) => $this->convertBytes($point['maxmem'] ?? 0, 'GB'), $data); return Chartjs::build() ->name('memoryChart_' . $this->server->id . '_' . $this->memoryTimeRange . '_' . $this->memoryAggregation) ->type('line') ->size(['height' => 280, 'width' => 400]) ->labels($labels) ->datasets([ [ 'label' => 'Used (GB)', 'data' => $used, 'borderColor' => 'rgb(96, 165, 250)', 'backgroundColor' => 'rgba(96, 165, 250, 0.15)', 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, ], [ 'label' => 'Provisioned (GB)', 'data' => $max, 'borderColor' => 'rgb(30, 64, 175)', 'backgroundColor' => 'rgba(37, 99, 235, 0.08)', 'fill' => false, 'pointRadius' => 0, 'borderDash' => [6, 6], ], ]) ->options($this->chartOptions(null, 'GB')); } protected function makeNetworkChart() { $data = $this->graphPayloads['network'] ?? []; if (empty($data)) { return null; } $labels = $this->buildLabels($data, $this->networkTimeRange); $download = array_map(fn ($point) => $this->convertBytes($point['netin'] ?? 0, 'MB'), $data); $upload = array_map(fn ($point) => $this->convertBytes($point['netout'] ?? 0, 'MB'), $data); return Chartjs::build() ->name('networkChart_' . $this->server->id . '_' . $this->networkTimeRange . '_' . $this->networkAggregation) ->type('line') ->size(['height' => 280, 'width' => 400]) ->labels($labels) ->datasets([ [ 'label' => 'Download (MB/s)', 'data' => $download, 'borderColor' => 'rgb(45, 212, 191)', 'backgroundColor' => 'rgba(45, 212, 191, 0.12)', 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, ], [ 'label' => 'Upload (MB/s)', 'data' => $upload, 'borderColor' => 'rgb(14, 165, 233)', 'backgroundColor' => 'rgba(14, 165, 233, 0.12)', 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, ], ]) ->options($this->chartOptions(null, 'MB/s')); } protected function makeDiskChart() { $data = $this->graphPayloads['disk'] ?? []; if (empty($data)) { return null; } $labels = $this->buildLabels($data, $this->diskTimeRange); $read = array_map(fn ($point) => $this->convertBytes($point['diskread'] ?? 0, 'MB'), $data); $write = array_map(fn ($point) => $this->convertBytes($point['diskwrite'] ?? 0, 'MB'), $data); return Chartjs::build() ->name('diskChart_' . $this->server->id . '_' . $this->diskTimeRange . '_' . $this->diskAggregation) ->type('line') ->size(['height' => 280, 'width' => 400]) ->labels($labels) ->datasets([ [ 'label' => 'Read (MB/s)', 'data' => $read, 'borderColor' => 'rgb(147, 51, 234)', 'backgroundColor' => 'rgba(147, 51, 234, 0.14)', 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, ], [ 'label' => 'Write (MB/s)', 'data' => $write, 'borderColor' => 'rgb(236, 72, 153)', 'backgroundColor' => 'rgba(236, 72, 153, 0.12)', 'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, ], ]) ->options($this->chartOptions(null, 'MB/s')); } protected function chartOptions(?int $suggestedMax = null, ?string $yLabel = null, bool $showLegend = true): array { $options = [ 'responsive' => true, 'maintainAspectRatio' => false, 'plugins' => [ 'legend' => [ 'display' => $showLegend, 'labels' => [ 'color' => 'rgb(148, 163, 184)', 'usePointStyle' => true, ], ], 'tooltip' => [ 'backgroundColor' => 'rgba(15, 23, 42, 0.92)', 'titleColor' => '#fff', 'bodyColor' => '#fff', ], ], 'elements' => [ 'point' => [ 'hitRadius' => 20, ], ], 'scales' => [ 'x' => [ 'grid' => [ 'color' => 'rgba(148, 163, 184, 0.12)', ], 'ticks' => [ 'color' => 'rgb(148, 163, 184)', 'maxRotation' => 0, ], ], 'y' => [ 'beginAtZero' => true, 'grid' => [ 'color' => 'rgba(148, 163, 184, 0.08)', ], 'ticks' => [ 'color' => 'rgb(148, 163, 184)', ], ], ], ]; if ($suggestedMax !== null) { $options['scales']['y']['suggestedMax'] = $suggestedMax; } if ($yLabel) { $options['scales']['y']['title'] = [ 'display' => true, 'text' => $yLabel, 'color' => 'rgb(148, 163, 184)', 'font' => ['size' => 12], ]; } return $options; } protected function buildLabels(array $data, string $range): array { $format = $this->labelFormatForRange($range); return array_map(function ($point) use ($format) { if (!isset($point['time'])) { return ''; } return Carbon::createFromTimestamp((int)$point['time'])->format($format); }, $data); } protected function labelFormatForRange(string $range): string { return match ($range) { 'hour' => 'H:i', 'day' => 'M d H:i', 'week' => 'M d', 'month' => 'M d', 'year' => 'M Y', default => 'M d H:i', }; } protected function convertBytes($bytes, string $unit = 'MB'): float { if (!is_numeric($bytes) || $bytes <= 0) { return 0.0; } $unit = strtoupper($unit); $divisors = [ 'B' => 1, 'KB' => 1024, 'MB' => 1024 ** 2, 'GB' => 1024 ** 3, 'TB' => 1024 ** 4, ]; $divisor = $divisors[$unit] ?? $divisors['MB']; return round($bytes / $divisor, 2); } /** * Extract IP address from a string, handling ports for both IPv4 and IPv6 */ protected function extractIPFromString(string $ip): string { $ip = trim($ip); // Handle IPv4 with port (e.g., "192.168.1.1:8080") if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $ip, $matches)) { return $matches[1]; } // Handle IPv6 with port in brackets (e.g., "[2001:db8::1]:8080") if (preg_match('/^\[([^\]]+)\]:(\d+)$/', $ip, $matches)) { return $matches[1]; } // Handle IPv6 with port without brackets (less common, but possible) // For IPv6, we need to check if it's actually an IPv6 address before removing port if (str_contains($ip, ':')) { // Check if it's a valid IPv6 address if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { // No port to remove, it's just an IPv6 address return $ip; } // Might be IPv6 with port, try to extract $parts = explode(']:', $ip); if (count($parts) === 2) { return trim($parts[0], '[]'); } } return $ip; } /** * Check if an IP address is public (not private/localhost) - public method for Blade templates */ public function checkIsPublicIP(string $ip): bool { return $this->isPublicIP($ip); } /** * Check if an IP address is public (not private/localhost) */ protected function isPublicIP(string $ip): bool { $ip = $this->extractIPFromString($ip); if (empty($ip)) { return false; } // Check for localhost if (in_array(strtolower($ip), ['localhost', '127.0.0.1', '::1'])) { return false; } // Validate and check if it's a public IP if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { // IPv4: Check if it's not in private ranges return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; } if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { // IPv6: Check if it's not in private ranges // Private IPv6 ranges: // - ::1 (localhost) // - fc00::/7 (Unique Local Address - ULA) // - fe80::/10 (Link-local) // - ::ffff:0:0/96 (IPv4-mapped IPv6) // Check for link-local (fe80::/10 - addresses starting with fe80 through febf) if (preg_match('/^fe[89ab][0-9a-fA-F]:/i', $ip)) { return false; } // Check for ULA (fc00::/7 means fc00:: to fdff:ffff:...) if (str_starts_with($ip, 'fc') || str_starts_with($ip, 'fd')) { return false; } // Check for IPv4-mapped IPv6 if (str_starts_with($ip, '::ffff:')) { $ipv4Part = substr($ip, 7); return $this->isPublicIP($ipv4Part); } // If it's a valid IPv6 and not in private ranges, consider it public return true; } return false; } /** * Fetch IP geolocation data from ip-api.com via HTTP (server-side proxy) * This avoids mixed content issues when the site is served over HTTPS */ public function fetchIPLocation($ip): array { if (empty($ip)) { return ['status' => 'fail', 'message' => 'IP address is required']; } // Extract and validate IP address $cleanIP = $this->extractIPFromString((string)$ip); if (!$this->isPublicIP($cleanIP)) { return ['status' => 'fail', 'message' => 'Not a valid public IP address']; } try { // Use HTTP protocol as requested (not HTTPS) $response = Http::timeout(5)->get("http://ip-api.com/json/{$cleanIP}"); if ($response->successful()) { return $response->json(); } return [ 'status' => 'fail', 'message' => 'Failed to fetch location data: HTTP ' . $response->status() ]; } catch (\Exception $e) { Log::error('Failed to fetch IP location', [ 'ip' => $cleanIP, 'original_ip' => $ip, 'error' => $e->getMessage() ]); return [ 'status' => 'fail', 'message' => 'Failed to fetch location data: ' . $e->getMessage() ]; } } }