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