Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/Statistics.php

4322 lines
180 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
use Carbon\Carbon;
use IcehouseVentures\LaravelChartjs\Facades\Chartjs;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\On;
use Livewire\WithPagination;
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
use Paymenter\Extensions\Servers\Proxmox\Models\ServerStatistics;
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
use Paymenter\Extensions\Servers\Proxmox\Services\CoreRouterClient;
class Statistics extends Component
{
use WithPagination;
public Server $server;
public $selectedStatisticId = null;
public $statisticToDeleteId = null;
public $showDeleteModal = false;
public $expandedTaskUpids = [];
public $showClearStatisticsModal = false;
public $showClearTaskHistoryModal = false;
public string $cpuTimeRange = 'hour';
public string $cpuAggregation = 'average';
public string $memoryTimeRange = 'hour';
public string $memoryAggregation = 'average';
public string $networkTimeRange = 'hour';
public string $networkAggregation = 'average';
public string $diskTimeRange = 'hour';
public string $diskAggregation = 'average';
public bool $showExportInfoModal = false;
public string $exportInfoMessage = '';
public array $graphPayloads = [
'cpu' => [],
'memory' => [],
'network' => [],
'disk' => [],
];
public bool $shouldRenderCharts = false;
public bool $networkAnalysisLoading = false;
public int $networkAnalysisRetryCount = 0;
public int $networkAnalysisMaxRetries = 3;
// Network Analysis Pagination
public int $connectionsPerPage = 25;
public int $connectionsCurrentPage = 1;
public int $topConnectionsPerPage = 25;
public int $topConnectionsCurrentPage = 1;
public int $topPortsPerPage = 25;
public int $topPortsCurrentPage = 1;
// Statistics History Pagination
public int $statisticsPerPage = 25;
// Task History Pagination
public int $taskHistoryPerPage = 25;
public int $taskHistoryCurrentPage = 1;
// Lifecycle Events Pagination
public int $lifecycleEventsPerPage = 25;
public int $lifecycleEventsCurrentPage = 1;
public array $networkAnalysis = [
'connectionCount' => 0,
'topConnectionCount' => 0,
'topPortCount' => 0,
'security' => [],
'lastUpdated' => null,
'error' => null,
];
public array $cachedServerIps = [];
public 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['connectionCount'] = 0;
$this->networkAnalysis['topConnectionCount'] = 0;
$this->networkAnalysis['topPortCount'] = 0;
$this->networkAnalysis['security'] = [];
}
// Preload graph data on mount to speed up initial display
// Dispatch individual chart events for more reliable parallel loading
// Each chart loads independently so a failure in one doesn't block others
$this->dispatch('proxmox:stats:cpu');
$this->dispatch('proxmox:stats:memory');
$this->dispatch('proxmox:stats:network');
$this->dispatch('proxmox:stats:disk');
}
#[On('proxmox:stats')]
public function stats(): void
{
// Fetch each graph independently to prevent one failure from blocking others
// Each fetch is wrapped in its own try-catch in fetchGraphData()
$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);
}
/**
* Fetch individual graph data - allows frontend to request specific graphs independently
* This improves reliability as each graph can be retried separately if it fails
*/
#[On('proxmox:stats:cpu')]
public function fetchCpuStats(): void
{
$this->fetchGraphData('cpu', $this->cpuTimeRange, $this->cpuAggregation);
}
#[On('proxmox:stats:memory')]
public function fetchMemoryStats(): void
{
$this->fetchGraphData('memory', $this->memoryTimeRange, $this->memoryAggregation);
}
#[On('proxmox:stats:network')]
public function fetchNetworkStats(): void
{
$this->fetchGraphData('network', $this->networkTimeRange, $this->networkAggregation);
}
#[On('proxmox:stats:disk')]
public function fetchDiskStats(): void
{
$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,
]);
// Only store lightweight metadata in the public property (not the heavy arrays)
$this->networkAnalysis = [
'connectionCount' => count($cachedAnalysis['connections'] ?? []),
'topConnectionCount' => count($cachedAnalysis['topConnections'] ?? []),
'topPortCount' => count($cachedAnalysis['topPorts'] ?? []),
'security' => $cachedAnalysis['security'] ?? [],
'lastUpdated' => $cachedAnalysis['lastUpdated'],
'error' => $cachedAnalysis['error'] ?? null,
];
$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['connectionCount'] = 0;
$this->networkAnalysis['topConnectionCount'] = 0;
$this->networkAnalysis['topPortCount'] = 0;
$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['connectionCount'] = 0;
$this->networkAnalysis['topConnectionCount'] = 0;
$this->networkAnalysis['topPortCount'] = 0;
$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),
]);
// Store all connections in cache only — NOT in the public property
// This prevents thousands of connections from being serialized in every Livewire request
$analysis = [
'connections' => $connections,
'topConnections' => $topConnections,
'topPorts' => $topPorts,
'security' => $securityWarnings,
'lastUpdated' => now()->toDateTimeString(),
'error' => null,
];
// Persist the full analysis in cache for pagination and export
Cache::put($cacheKey, $analysis, now()->addSeconds(30));
// Only store lightweight metadata in the Livewire public property
$this->networkAnalysis = [
'connectionCount' => count($connections),
'topConnectionCount' => count($topConnections),
'topPortCount' => count($topPorts),
'security' => $securityWarnings,
'lastUpdated' => now()->toDateTimeString(),
'error' => null,
];
// Reset pagination to page 1 when data is refreshed
$this->connectionsCurrentPage = 1;
$this->topConnectionsCurrentPage = 1;
$this->topPortsCurrentPage = 1;
// 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)');
// Reset counts and security on error
$this->networkAnalysis['connectionCount'] = 0;
$this->networkAnalysis['topConnectionCount'] = 0;
$this->networkAnalysis['topPortCount'] = 0;
$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();
}
// ==========================================
// Network Analysis Pagination Methods
// ==========================================
/**
* Get paginated connections for display
*/
public function getPaginatedConnectionsProperty(): array
{
$cacheKey = 'proxmox_network_analysis_' . $this->server->id;
$cachedAnalysis = Cache::get($cacheKey, []);
$connections = $cachedAnalysis['connections'] ?? [];
$offset = ($this->connectionsCurrentPage - 1) * $this->connectionsPerPage;
return array_slice($connections, $offset, $this->connectionsPerPage);
}
/**
* Get total pages for connections
*/
public function getConnectionsTotalPagesProperty(): int
{
$total = $this->networkAnalysis['connectionCount'] ?? 0;
return max(1, (int) ceil($total / $this->connectionsPerPage));
}
/**
* Get paginated top connections for display
*/
public function getPaginatedTopConnectionsProperty(): array
{
$cacheKey = 'proxmox_network_analysis_' . $this->server->id;
$cachedAnalysis = Cache::get($cacheKey, []);
$connections = $cachedAnalysis['topConnections'] ?? [];
$offset = ($this->topConnectionsCurrentPage - 1) * $this->topConnectionsPerPage;
return array_slice($connections, $offset, $this->topConnectionsPerPage);
}
/**
* Get total pages for top connections
*/
public function getTopConnectionsTotalPagesProperty(): int
{
$total = $this->networkAnalysis['topConnectionCount'] ?? 0;
return max(1, (int) ceil($total / $this->topConnectionsPerPage));
}
/**
* Get paginated top ports for display
*/
public function getPaginatedTopPortsProperty(): array
{
$cacheKey = 'proxmox_network_analysis_' . $this->server->id;
$cachedAnalysis = Cache::get($cacheKey, []);
$ports = $cachedAnalysis['topPorts'] ?? [];
$offset = ($this->topPortsCurrentPage - 1) * $this->topPortsPerPage;
return array_slice($ports, $offset, $this->topPortsPerPage);
}
/**
* Get total pages for top ports
*/
public function getTopPortsTotalPagesProperty(): int
{
$total = $this->networkAnalysis['topPortCount'] ?? 0;
return max(1, (int) ceil($total / $this->topPortsPerPage));
}
/**
* Reset page to 1 when perPage changes for connections
*/
public function updatedConnectionsPerPage(): void
{
$this->connectionsCurrentPage = 1;
}
/**
* Reset page to 1 when perPage changes for top connections
*/
public function updatedTopConnectionsPerPage(): void
{
$this->topConnectionsCurrentPage = 1;
}
/**
* Reset page to 1 when perPage changes for top ports
*/
public function updatedTopPortsPerPage(): void
{
$this->topPortsCurrentPage = 1;
}
/**
* Navigate to a specific page for connections
*/
public function goToConnectionsPage(int $page): void
{
$this->connectionsCurrentPage = max(1, min($page, $this->connectionsTotalPages));
}
/**
* Navigate to previous page for connections
*/
public function previousConnectionsPage(): void
{
$this->goToConnectionsPage($this->connectionsCurrentPage - 1);
}
/**
* Navigate to next page for connections
*/
public function nextConnectionsPage(): void
{
$this->goToConnectionsPage($this->connectionsCurrentPage + 1);
}
/**
* Navigate to a specific page for top connections
*/
public function goToTopConnectionsPage(int $page): void
{
$this->topConnectionsCurrentPage = max(1, min($page, $this->topConnectionsTotalPages));
}
/**
* Navigate to previous page for top connections
*/
public function previousTopConnectionsPage(): void
{
$this->goToTopConnectionsPage($this->topConnectionsCurrentPage - 1);
}
/**
* Navigate to next page for top connections
*/
public function nextTopConnectionsPage(): void
{
$this->goToTopConnectionsPage($this->topConnectionsCurrentPage + 1);
}
/**
* Navigate to a specific page for top ports
*/
public function goToTopPortsPage(int $page): void
{
$this->topPortsCurrentPage = max(1, min($page, $this->topPortsTotalPages));
}
/**
* Navigate to previous page for top ports
*/
public function previousTopPortsPage(): void
{
$this->goToTopPortsPage($this->topPortsCurrentPage - 1);
}
/**
* Navigate to next page for top ports
*/
public function nextTopPortsPage(): void
{
$this->goToTopPortsPage($this->topPortsCurrentPage + 1);
}
// ==========================================
// Statistics History Pagination Methods
// ==========================================
/**
* Reset to page 1 when perPage changes for statistics
* (Laravel paginator will automatically handle this with the paginate() method)
*/
public function updatedStatisticsPerPage(): void
{
// Reset to page 1 when changing per-page (handled by Livewire automatically)
$this->resetPage();
}
// ==========================================
// Task History Pagination Methods
// ==========================================
/**
* Get paginated task history for display
*/
public function getPaginatedTaskHistoryProperty(): array
{
$tasks = $this->taskHistory;
if (empty($tasks)) {
return [];
}
$offset = ($this->taskHistoryCurrentPage - 1) * $this->taskHistoryPerPage;
return array_slice($tasks, $offset, $this->taskHistoryPerPage);
}
/**
* Get total pages for task history
*/
public function getTaskHistoryTotalPagesProperty(): int
{
$total = count($this->taskHistory);
return max(1, (int) ceil($total / $this->taskHistoryPerPage));
}
/**
* Reset page to 1 when perPage changes for task history
*/
public function updatedTaskHistoryPerPage(): void
{
$this->taskHistoryCurrentPage = 1;
}
/**
* Navigate to a specific page for task history
*/
public function goToTaskHistoryPage(int $page): void
{
$this->taskHistoryCurrentPage = max(1, min($page, $this->taskHistoryTotalPages));
}
/**
* Navigate to previous page for task history
*/
public function previousTaskHistoryPage(): void
{
$this->goToTaskHistoryPage($this->taskHistoryCurrentPage - 1);
}
/**
* Navigate to next page for task history
*/
public function nextTaskHistoryPage(): void
{
$this->goToTaskHistoryPage($this->taskHistoryCurrentPage + 1);
}
// ==========================================
// Lifecycle Events Pagination Methods
// ==========================================
/**
* Get paginated lifecycle events for display
*/
public function getPaginatedLifecycleEventsProperty(): array
{
$events = collect($this->chartEvents)->values()->all();
if (empty($events)) {
return [];
}
$offset = ($this->lifecycleEventsCurrentPage - 1) * $this->lifecycleEventsPerPage;
return array_slice($events, $offset, $this->lifecycleEventsPerPage);
}
/**
* Get total pages for lifecycle events
*/
public function getLifecycleEventsTotalPagesProperty(): int
{
$total = count($this->chartEvents);
return max(1, (int) ceil($total / $this->lifecycleEventsPerPage));
}
/**
* Reset page to 1 when perPage changes for lifecycle events
*/
public function updatedLifecycleEventsPerPage(): void
{
$this->lifecycleEventsCurrentPage = 1;
}
/**
* Navigate to a specific page for lifecycle events
*/
public function goToLifecycleEventsPage(int $page): void
{
$this->lifecycleEventsCurrentPage = max(1, min($page, $this->lifecycleEventsTotalPages));
}
/**
* Navigate to previous page for lifecycle events
*/
public function previousLifecycleEventsPage(): void
{
$this->goToLifecycleEventsPage($this->lifecycleEventsCurrentPage - 1);
}
/**
* Navigate to next page for lifecycle events
*/
public function nextLifecycleEventsPage(): void
{
$this->goToLifecycleEventsPage($this->lifecycleEventsCurrentPage + 1);
}
/**
* Targeted refresh for task history and lifecycle events sections.
* Replaces wire:poll="$refresh" which would re-evaluate ALL computed
* properties (graphs, network analysis, etc.) causing unnecessary load.
*/
public function refreshTaskData(): void
{
// Invalidate the task history cache so it re-fetches from API
Cache::forget('proxmox_task_history_' . $this->server->id);
}
/**
* 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, bool $forceRefresh = false): void
{
$allowedRanges = ['hour', 'day', 'week', 'month', 'year'];
$allowedAggs = ['average', 'maximum'];
if (!in_array($timeRange, $allowedRanges, true) || !in_array($aggregation, $allowedAggs, true)) {
return;
}
$cacheKey = 'proxmox-stats-' . $this->server->id . '-' . $graphType . '-' . $timeRange . '-' . $aggregation;
try {
if ($forceRefresh) {
Cache::forget($cacheKey);
}
// 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;
$vmType = $this->server->node->vmType();
$stats = $proxmox->request('/nodes/' . $this->server->node->name . '/' . $vmType . '/' . $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->graphPayloads[$graphType] = is_array($data) ? $data : [];
$this->dispatch('proxmox:stats:' . $this->server->id . ':' . $graphType, $this->graphPayloads[$graphType]);
}
#[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($this->statisticsPerPage);
}
public function deleteStatistic($statisticId)
{
if (!$statisticId) {
return;
}
try {
$statistic = ServerStatistics::where('id', $statisticId)
->where('server_id', $this->server->id)
->first();
if (!$statistic) {
$this->notify('Statistics entry not found', 'error');
return;
}
// Clear selection if this is the selected statistic
if ($this->selectedStatisticId == $statisticId) {
$this->selectedStatisticId = null;
}
$statistic->delete();
$this->notify('Statistics entry deleted successfully', 'success');
} catch (\Throwable $e) {
$this->notify('Error deleting statistics: ' . $e->getMessage(), 'error');
}
}
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()
{
$cacheKey = 'proxmox_task_history_' . $this->server->id;
try {
return Cache::remember($cacheKey, 30, function () {
return $this->fetchTaskHistoryFromApi();
});
} catch (\Throwable $e) {
\Log::error('Error fetching task history for server ' . $this->server->id . ': ' . $e->getMessage());
return [];
}
}
/**
* Fetch task history directly from the Proxmox API.
* Separated from the computed property so Cache::remember can wrap it.
*/
protected function fetchTaskHistoryFromApi(): array
{
$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);
}
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 90 minutes)
// Using 90 minutes to ensure we catch hourly statistics even with timing variations
$minTime = $targetTime->copy()->subMinutes(90);
$maxTime = $targetTime->copy()->addMinutes(90);
$stats = ServerStatistics::where('server_id', $this->server->id)
->whereBetween('DateTime', [$minTime, $maxTime])
->orderBy('DateTime', 'asc')
->get();
if ($stats->isEmpty()) {
// Fallback: get the closest stat before or after target time
$closestBefore = ServerStatistics::where('server_id', $this->server->id)
->where('DateTime', '<=', $targetTime)
->orderBy('DateTime', 'desc')
->first();
$closestAfter = ServerStatistics::where('server_id', $this->server->id)
->where('DateTime', '>=', $targetTime)
->orderBy('DateTime', 'asc')
->first();
if (!$closestBefore && !$closestAfter) {
return null;
}
if (!$closestBefore) return $closestAfter;
if (!$closestAfter) return $closestBefore;
// Return whichever is closer
$diffBefore = abs($closestBefore->DateTime->diffInSeconds($targetTime));
$diffAfter = abs($closestAfter->DateTime->diffInSeconds($targetTime));
return $diffBefore <= $diffAfter ? $closestBefore : $closestAfter;
}
// 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 || isset($comparison['error'])) {
return [];
}
$current = $comparison['current'] ?? null;
$historical = $comparison['historical'] ?? null;
$diff = $comparison['differences'] ?? null;
if (!$current || !$historical || !$diff) {
return [];
}
$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()
{
$cacheKey = 'proxmox_network_analysis_' . $this->server->id;
$cachedAnalysis = Cache::get($cacheKey, []);
$connections = $cachedAnalysis['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(int|float $bytes): string
{
if ($bytes == 0) return '0 B';
$prefix = '';
if ($bytes < 0) {
$prefix = '-';
$bytes = abs($bytes);
}
$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 $prefix . 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
// Check dedicated port fields first, then fall back to composite IP[port] fields
$portOnlyCandidates = [
"{$direction}_port",
"{$direction}port",
$direction === 'src' ? 'source_port' : 'destination_port',
$direction === 'src' ? 'sport' : 'dport',
];
foreach ($portOnlyCandidates as $candidate) {
if (isset($state[$candidate]) && is_numeric($state[$candidate])) {
return (int) $state[$candidate];
}
}
// Fall back to extracting port from composite "IP[port]" or "IP:port" fields
$compositeCandidates = [
$direction === 'src' ? 'source' : 'destination',
];
foreach ($compositeCandidates as $candidate) {
if (isset($state[$candidate]) && is_string($state[$candidate])) {
$value = $state[$candidate];
// Handle "IP[port]" format
if (preg_match('/\[(\d+)\]$/', $value, $matches)) {
return (int) $matches[1];
}
// Handle IPv4 "IP:port" format (not IPv6)
if (preg_match('/^(\d+\.\d+\.\d+\.\d+):(\d+)$/', $value, $matches)) {
return (int) $matches[2];
}
}
}
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;
// Apply mask to the partial byte first (before zeroing), preserving network bits
if ($remainingBits > 0 && $fullBytes < strlen($ipBinary)) {
$mask = ~((1 << (8 - $remainingBits)) - 1) & 0xFF;
$ipBinary[$fullBytes] = chr(ord($ipBinary[$fullBytes]) & $mask);
}
// Zero out all host bytes after the partial byte
for ($i = $fullBytes + ($remainingBits > 0 ? 1 : 0); $i < strlen($ipBinary); $i++) {
$ipBinary[$i] = "\0";
}
$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
{
$allowedTypes = ['cpu', 'memory', 'network', 'disk'];
if (!in_array($chartType, $allowedTypes, true)) {
return [];
}
$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()
];
}
}
}